diff --git a/Cargo.lock b/Cargo.lock index 5c8405d09..3891a1ce9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -772,7 +772,7 @@ dependencies = [ "url", "utoipa", "uuid", - "vitaminc", + "vitaminc 0.5.1", ] [[package]] @@ -2336,6 +2336,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.17+spec-1.1.0", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -3227,8 +3236,8 @@ dependencies = [ "tracing-subscriber", "url", "uuid", - "vitaminc", - "vitaminc-protected", + "vitaminc 0.6.0", + "vitaminc-protected 0.6.0", "web-time", "zeroize", "zerokms-protocol", @@ -3250,7 +3259,7 @@ dependencies = [ "tempfile", "tokio", "url", - "vitaminc-protected", + "vitaminc-protected 0.6.0", "zeroize", ] @@ -3275,7 +3284,7 @@ dependencies = [ [[package]] name = "stack-encrypt" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64ct", "cllw-ore", @@ -3289,19 +3298,18 @@ dependencies = [ "tokio", "trybuild", "uuid", - "vitaminc-aead", - "vitaminc-aead-value", - "vitaminc-encrypt", + "vitaminc-aead 0.6.0", + "vitaminc-encrypt 0.6.0", "vitaminc-hmac", "vitaminc-prf", - "vitaminc-protected", + "vitaminc-protected 0.6.0", "zeroize", "zerokms-protocol", ] [[package]] name = "stack-encrypt-derive" -version = "0.2.0" +version = "0.3.0" dependencies = [ "proc-macro2", "quote", @@ -3319,8 +3327,9 @@ dependencies = [ "proptest", "serde_json", "thiserror 1.0.69", - "vitaminc-aead-value", - "vitaminc-protected", + "vitaminc-aead 0.6.0", + "vitaminc-aead-frame", + "vitaminc-protected 0.6.0", "zeroize", ] @@ -3352,8 +3361,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc", - "vitaminc-protected", + "vitaminc 0.6.0", + "vitaminc-protected 0.6.0", "zeroize", "zerokms-protocol", ] @@ -3688,7 +3697,7 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", - "toml_edit", + "toml_edit 0.22.27", ] [[package]] @@ -3703,7 +3712,7 @@ dependencies = [ "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.14", ] [[package]] @@ -3724,6 +3733,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -3735,16 +3753,28 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.14", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ - "winnow", + "winnow 1.0.4", ] [[package]] @@ -4081,12 +4111,26 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ - "vitaminc-aead", - "vitaminc-context", - "vitaminc-encrypt", - "vitaminc-protected", - "vitaminc-random", - "vitaminc-traits", + "vitaminc-aead 0.5.1", + "vitaminc-context 0.5.1", + "vitaminc-encrypt 0.5.1", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "vitaminc-traits 0.5.1", +] + +[[package]] +name = "vitaminc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0c6b8dff9bfc94e076fcd5496395958e6c8dddb27605b6504c54123512c7c8e" +dependencies = [ + "vitaminc-aead 0.6.0", + "vitaminc-context 0.6.0", + "vitaminc-encrypt 0.6.0", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", + "vitaminc-traits 0.6.0", ] [[package]] @@ -4097,10 +4141,27 @@ checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", - "vitaminc-aead-derive", - "vitaminc-context", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-aead-derive 0.5.1", + "vitaminc-context 0.5.1", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-aead" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcea687f745050d80df32c93fbe7f8d18ed2cc5fb5e029814477dfe31449ec2e" +dependencies = [ + "bytes", + "futures-util", + "serde", + "thiserror 2.0.18", + "vitaminc-aead-derive 0.6.0", + "vitaminc-context 0.6.0", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", "zeroize", ] @@ -4116,14 +4177,23 @@ dependencies = [ ] [[package]] -name = "vitaminc-aead-value" -version = "0.5.1" +name = "vitaminc-aead-derive" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d78cdadbc6374c3bd7c19fb4d99abca693b96953b18fef8c608efbe092cf3ea" +checksum = "085afc8c77ffadb4c8a93c9c5fa2d897196a36ab38e17c08fe96f8b9c309e05f" dependencies = [ - "vitaminc-aead", - "vitaminc-protected", - "zeroize", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-aead-frame" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c253055230469c4af4791e902c41ce0ce55069c512249b33cf041dd5c58c517" +dependencies = [ + "vitaminc-aead 0.6.0", ] [[package]] @@ -4133,7 +4203,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", - "vitaminc-protected", + "vitaminc-protected 0.5.1", +] + +[[package]] +name = "vitaminc-context" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4d5679e511871da942ba2b29fcb1df198ee2359218bf7f11cfec4fd3dcfeee0" +dependencies = [ + "mutants", + "vitaminc-protected 0.6.0", ] [[package]] @@ -4144,35 +4224,49 @@ checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-aead 0.5.1", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061d55dc4084fcc2eaa7334dd28300cd8d321038339a41085b7bf2dc6ccf5d4c" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead 0.6.0", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15fe558a5e92d4f2b3f811c5b67d863d9255da5f66d08a71d4f0dd3d0011c563" +checksum = "393354b2348eabce0420626fdc7cf70ac779870f82abb566fb491ea19eeea066" dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected", + "vitaminc-protected 0.6.0", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712d31d46d76b38c6b62deb35f516fc5636a35f90f39c0213d0ca9a052b4400b" +checksum = "11de28e524ab1deecedbb023bd328916b6c023e2f52156521ac1df75a689d911" dependencies = [ "mutants", "thiserror 2.0.18", - "vitaminc-context", - "vitaminc-protected", + "vitaminc-context 0.6.0", + "vitaminc-protected 0.6.0", ] [[package]] @@ -4188,7 +4282,24 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive", + "vitaminc-protected-derive 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-protected" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efe97c67b61fd749b20d84248416a6cc1dc3f024a4695e3f69eee63aee7cb7c2" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.18", + "vitaminc-protected-derive 0.6.0", "zeroize", ] @@ -4203,6 +4314,18 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "vitaminc-protected-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0483ecfcb3e874d45c9b2dc04d8e5ed55ff453023a9de98fddc4a24b315d9f6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "vitaminc-random" version = "0.5.1" @@ -4213,8 +4336,23 @@ dependencies = [ "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected", - "vitaminc-random-derives", + "vitaminc-protected 0.5.1", + "vitaminc-random-derives 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-random" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693ae19994e6a79f9839e510220f532daab6f2ab0b4167fdd0d8379a027c653f" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand 0.10.1", + "thiserror 2.0.18", + "vitaminc-protected 0.6.0", + "vitaminc-random-derives 0.6.0", "zeroize", ] @@ -4229,6 +4367,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "vitaminc-random-derives" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fb9ad6bb972f7e1b0889eba0a3ed534afe86dd8e52f197a2de20ebe25ee9e56" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "vitaminc-traits" version = "0.5.1" @@ -4240,8 +4389,24 @@ dependencies = [ "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-traits" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c9f86c930cb4f98eee9288f463a133f01774be039ba05a092f12148b45c430c" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.18", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", "zeroize", ] @@ -4873,6 +5038,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" diff --git a/Cargo.toml b/Cargo.toml index 3da394ab4..325540829 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,13 +120,12 @@ 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.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). -# 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" +vitaminc = { version = "0.6.0", features = ["random", "protected", "encrypt"] } +vitaminc-aead = "0.6.0" +# The transport frame and ciphertext codec every language binding carries +# across its FFI boundary. Optional in stack-encrypt (the `dynamic` feature). +vitaminc-aead-frame = "0.6.0" +vitaminc-encrypt = "0.6.0" +vitaminc-hmac = "0.6.0" +vitaminc-prf = "0.6.0" +vitaminc-protected = "0.6.0" diff --git a/languages/golang/auth/guest/Cargo.lock b/languages/golang/auth/guest/Cargo.lock index 1616b6d77..58ae17c2a 100644 --- a/languages/golang/auth/guest/Cargo.lock +++ b/languages/golang/auth/guest/Cargo.lock @@ -419,7 +419,7 @@ dependencies = [ "url", "utoipa", "uuid", - "vitaminc", + "vitaminc 0.5.1", ] [[package]] @@ -1365,6 +1365,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -1838,8 +1847,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc", - "vitaminc-protected", + "vitaminc 0.6.0", + "vitaminc-protected 0.6.0", "web-time", "zeroize", "zerokms-protocol", @@ -1858,8 +1867,7 @@ dependencies = [ "stack-profile", "tempfile", "url", - "vitaminc-aead-value", - "vitaminc-protected", + "vitaminc-protected 0.6.0", "zeroize", ] @@ -1870,8 +1878,9 @@ dependencies = [ "miette", "serde_json", "thiserror 1.0.69", - "vitaminc-aead-value", - "vitaminc-protected", + "vitaminc-aead 0.6.0", + "vitaminc-aead-frame", + "vitaminc-protected 0.6.0", "zeroize", ] @@ -2066,6 +2075,36 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2250,12 +2289,26 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ - "vitaminc-aead", - "vitaminc-context", - "vitaminc-encrypt", - "vitaminc-protected", - "vitaminc-random", - "vitaminc-traits", + "vitaminc-aead 0.5.1", + "vitaminc-context 0.5.1", + "vitaminc-encrypt 0.5.1", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "vitaminc-traits 0.5.1", +] + +[[package]] +name = "vitaminc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0c6b8dff9bfc94e076fcd5496395958e6c8dddb27605b6504c54123512c7c8e" +dependencies = [ + "vitaminc-aead 0.6.0", + "vitaminc-context 0.6.0", + "vitaminc-encrypt 0.6.0", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", + "vitaminc-traits 0.6.0", ] [[package]] @@ -2266,10 +2319,27 @@ checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", - "vitaminc-aead-derive", - "vitaminc-context", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-aead-derive 0.5.1", + "vitaminc-context 0.5.1", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-aead" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcea687f745050d80df32c93fbe7f8d18ed2cc5fb5e029814477dfe31449ec2e" +dependencies = [ + "bytes", + "futures-util", + "serde", + "thiserror 2.0.20", + "vitaminc-aead-derive 0.6.0", + "vitaminc-context 0.6.0", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", "zeroize", ] @@ -2285,14 +2355,23 @@ dependencies = [ ] [[package]] -name = "vitaminc-aead-value" -version = "0.5.1" +name = "vitaminc-aead-derive" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d78cdadbc6374c3bd7c19fb4d99abca693b96953b18fef8c608efbe092cf3ea" +checksum = "085afc8c77ffadb4c8a93c9c5fa2d897196a36ab38e17c08fe96f8b9c309e05f" dependencies = [ - "vitaminc-aead", - "vitaminc-protected", - "zeroize", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "vitaminc-aead-frame" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c253055230469c4af4791e902c41ce0ce55069c512249b33cf041dd5c58c517" +dependencies = [ + "vitaminc-aead 0.6.0", ] [[package]] @@ -2302,7 +2381,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", - "vitaminc-protected", + "vitaminc-protected 0.5.1", +] + +[[package]] +name = "vitaminc-context" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4d5679e511871da942ba2b29fcb1df198ee2359218bf7f11cfec4fd3dcfeee0" +dependencies = [ + "mutants", + "vitaminc-protected 0.6.0", ] [[package]] @@ -2313,9 +2402,23 @@ checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-aead 0.5.1", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061d55dc4084fcc2eaa7334dd28300cd8d321038339a41085b7bf2dc6ccf5d4c" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead 0.6.0", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", "zeroize", ] @@ -2332,7 +2435,24 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.20", - "vitaminc-protected-derive", + "vitaminc-protected-derive 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-protected" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efe97c67b61fd749b20d84248416a6cc1dc3f024a4695e3f69eee63aee7cb7c2" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.20", + "vitaminc-protected-derive 0.6.0", "zeroize", ] @@ -2347,6 +2467,18 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "vitaminc-protected-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0483ecfcb3e874d45c9b2dc04d8e5ed55ff453023a9de98fddc4a24b315d9f6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "vitaminc-random" version = "0.5.1" @@ -2357,8 +2489,23 @@ dependencies = [ "getrandom 0.4.3", "rand 0.10.3", "thiserror 2.0.20", - "vitaminc-protected", - "vitaminc-random-derives", + "vitaminc-protected 0.5.1", + "vitaminc-random-derives 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-random" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693ae19994e6a79f9839e510220f532daab6f2ab0b4167fdd0d8379a027c653f" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand 0.10.3", + "thiserror 2.0.20", + "vitaminc-protected 0.6.0", + "vitaminc-random-derives 0.6.0", "zeroize", ] @@ -2373,6 +2520,17 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "vitaminc-random-derives" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fb9ad6bb972f7e1b0889eba0a3ed534afe86dd8e52f197a2de20ebe25ee9e56" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "vitaminc-traits" version = "0.5.1" @@ -2384,8 +2542,24 @@ dependencies = [ "rmp-serde", "serde", "thiserror 2.0.20", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-traits" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c9f86c930cb4f98eee9288f463a133f01774be039ba05a092f12148b45c430c" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.20", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", "zeroize", ] @@ -2622,6 +2796,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/languages/golang/auth/guest/Cargo.toml b/languages/golang/auth/guest/Cargo.toml index d3928e995..df3ce1563 100644 --- a/languages/golang/auth/guest/Cargo.toml +++ b/languages/golang/auth/guest/Cargo.toml @@ -41,10 +41,6 @@ stack-guest-abi = { path = "../../../../packages/stack-guest-abi" } # hostname and the process id are native-only there, and creating a device # identity is CLI territory. stack-profile = { path = "../../../../packages/stack-profile" } -# The FFI codec for the structured results (a list of workspace ids, the -# fields of a token): the one codec every binding carries. Same vitaminc -# version the crypto guest builds against. -vitaminc-aead-value = "0.5.0" # The deserialized form of `secretkey.json` is wiped when it drops. zeroize = { version = "1", features = ["derive"] } @@ -52,7 +48,7 @@ zeroize = { version = "1", features = ["derive"] } serde_json = "1" tempfile = "3" # `Controlled::risky_ref`, to read a codec value back in the tests. -vitaminc-protected = "0.5.0" +vitaminc-protected = "0.6.0" [profile.release] # Smaller .wasm; the guest is IO-bound on the FFI copy and the file reads, diff --git a/languages/golang/auth/guest/src/lib.rs b/languages/golang/auth/guest/src/lib.rs index fb9986d4c..a95e5c511 100644 --- a/languages/golang/auth/guest/src/lib.rs +++ b/languages/golang/auth/guest/src/lib.rs @@ -58,8 +58,8 @@ //! //! Profile inputs are UTF-8 strings: the store directory (a guest path under //! the mount), a workspace id, a filename. Outputs are either a UTF-8 string -//! (an id, a path) or a value in vitaminc's FFI codec -//! (`vitaminc_aead_value::transport`): a list of ids, or the fields of +//! (an id, a path) or a vitaminc transport frame +//! ([`HostValue`](stack_guest_abi::value::HostValue)): a list of ids, or the fields of //! `secretkey.json`, `auth.json` or `device.json` as an object. The client //! key in `secretkey.json` crosses as the text the file holds; the Go side //! wraps it in its opaque `ClientKey` and wipes its transport copy. A diff --git a/languages/golang/auth/guest/src/ops.rs b/languages/golang/auth/guest/src/ops.rs index 6a32bfa5d..19e80580a 100644 --- a/languages/golang/auth/guest/src/ops.rs +++ b/languages/golang/auth/guest/src/ops.rs @@ -30,8 +30,8 @@ use serde::Deserialize; use stack_auth::Token; +use stack_guest_abi::value::HostValue; use stack_profile::{DeviceIdentity, ProfileStore}; -use vitaminc_aead_value::{transport as codec, FfiValue}; use zeroize::{Zeroize, ZeroizeOnDrop}; use stack_guest_abi::last_error::{internal, malformed}; @@ -76,18 +76,18 @@ fn text(bytes: &[u8]) -> Result<&str, u32> { std::str::from_utf8(bytes).map_err(|_| malformed("an input is not UTF-8")) } -fn string(value: impl Into) -> FfiValue { - FfiValue::String(value.into().into()) +fn string(value: impl Into) -> HostValue { + HostValue::text(value) } -fn optional(value: Option<&str>) -> FfiValue { - value.map_or(FfiValue::Null, string) +fn optional(value: Option<&str>) -> HostValue { + value.map_or(HostValue::Null, string) } -fn encode(value: FfiValue) -> Result, u32> { - let mut out = Vec::new(); - codec::encode_value(value, &mut out).map_err(|_| internal("an output did not encode"))?; - Ok(out) +fn encode(value: HostValue) -> Result, u32> { + value + .encode() + .map_err(|_| internal("an output did not encode")) } /// The current workspace id, as text. @@ -122,7 +122,7 @@ pub fn list_workspaces(dir: &[u8]) -> Result, u32> { let ids = store(dir)? .list_workspaces() .map_err(|e| fail_profile(&e))?; - encode(FfiValue::Array(ids.into_iter().map(string).collect())) + encode(HostValue::List(ids.into_iter().map(string).collect())) } /// The directory of the store scoped to workspace `id`, as text: what @@ -165,11 +165,11 @@ pub fn secret_key(dir: &[u8]) -> Result, u32> { // fields cannot be moved out of it directly. let client_id = std::mem::take(&mut file.client_id); let client_key = std::mem::take(&mut file.client_key); - encode(FfiValue::Object(vec![ + encode(HostValue::Map(vec![ ("client_id".to_string(), string(client_id)), ( "client_key".to_string(), - FfiValue::Bytes(client_key.into_bytes().into()), + HostValue::Bytes(client_key.into_bytes().into()), ), ])) } @@ -184,7 +184,7 @@ pub fn token(dir: &[u8]) -> Result, u32> { let token: Token = store(dir)? .load(AUTH_FILENAME) .map_err(|e| fail_profile(&e))?; - encode(FfiValue::Object(vec![ + encode(HostValue::Map(vec![ ( "access_token".to_string(), string(token.access_token().as_str()), @@ -192,7 +192,7 @@ pub fn token(dir: &[u8]) -> Result, u32> { ("token_type".to_string(), string(token.token_type())), ( "expires_at".to_string(), - FfiValue::UInt64(token.expires_at()), + HostValue::UInt(token.expires_at()), ), ("region".to_string(), optional(token.region())), ("client_id".to_string(), optional(token.client_id())), @@ -214,7 +214,7 @@ pub fn has_token(dir: &[u8]) -> Result, u32> { /// `{device_instance_id, device_name}`. Creating one is the CLI's. pub fn device_identity(dir: &[u8]) -> Result, u32> { let identity = DeviceIdentity::load(&store(dir)?).map_err(|e| fail_profile(&e))?; - encode(FfiValue::Object(vec![ + encode(HostValue::Map(vec![ ( "device_instance_id".to_string(), string(identity.device_instance_id.to_string()), @@ -235,12 +235,12 @@ mod tests { t.path().to_str().unwrap().as_bytes().to_vec() } - fn decode(bytes: &[u8]) -> FfiValue { - codec::decode_value(&mut codec::Reader::new(bytes)).unwrap() + fn decode(bytes: &[u8]) -> HostValue { + HostValue::decode(bytes).unwrap() } - fn field<'a>(value: &'a FfiValue, key: &str) -> &'a FfiValue { - let FfiValue::Object(entries) = value else { + fn field<'a>(value: &'a HostValue, key: &str) -> &'a HostValue { + let HostValue::Map(entries) = value else { panic!("not an object"); }; entries @@ -250,16 +250,16 @@ mod tests { .unwrap_or_else(|| panic!("no field {key}")) } - fn as_text(value: &FfiValue) -> String { - let FfiValue::String(s) = value else { + fn as_text(value: &HostValue) -> String { + let HostValue::Text(s) = value else { panic!("not a string"); }; - String::from_utf8(s.risky_ref().to_vec()).unwrap() + s.to_string() } - fn as_bytes(value: &FfiValue) -> Vec { + fn as_bytes(value: &HostValue) -> Vec { use vitaminc_protected::Controlled; - let FfiValue::Bytes(b) = value else { + let HostValue::Bytes(b) = value else { panic!("not bytes"); }; b.risky_ref().to_vec() @@ -314,7 +314,7 @@ mod tests { assert_eq!(current_workspace(&d).unwrap(), WS_A.as_bytes()); let listed = decode(&list_workspaces(&d).unwrap()); - let FfiValue::Array(items) = listed else { + let HostValue::List(items) = listed else { panic!("not an array"); }; let ids: Vec = items.iter().map(as_text).collect(); @@ -411,11 +411,11 @@ mod tests { assert_eq!(as_text(field(&tok, "token_type")), "Bearer"); assert!(matches!( field(&tok, "expires_at"), - FfiValue::UInt64(1_800_000_000) + HostValue::UInt(1_800_000_000) )); assert_eq!(as_text(field(&tok, "region")), "ap-southeast-2"); - assert!(matches!(field(&tok, "client_id"), FfiValue::Null)); - let FfiValue::Object(entries) = tok else { + assert!(matches!(field(&tok, "client_id"), HostValue::Null)); + let HostValue::Map(entries) = tok else { panic!("not an object"); }; assert!( diff --git a/languages/golang/auth/guest/tests/leak.rs b/languages/golang/auth/guest/tests/leak.rs index 6d08d4cf5..ae0083d80 100644 --- a/languages/golang/auth/guest/tests/leak.rs +++ b/languages/golang/auth/guest/tests/leak.rs @@ -25,7 +25,7 @@ use stack_auth::AuthError; use stack_auth_guest::ops; use stack_auth_guest::status::fail_auth; use stack_guest_abi::call; -use vitaminc_aead_value::{transport as codec, FfiValue}; +use stack_guest_abi::value::HostValue; const ACCESS_TOKEN: &str = "leak-marker-access-token"; const ACCESS_KEY: &str = "leak-marker-access-key"; @@ -43,18 +43,18 @@ fn failure( what: &str, expect: &[&str], step: impl FnOnce() -> Result, -) -> FfiValue { +) -> HostValue { let _ = call::run(step).expect_err(what); let bytes = call::take_last_error().unwrap_or_else(|| panic!("{what}: no error recorded")); - let error = codec::decode_value(&mut codec::Reader::new(&bytes)).expect("an error decodes"); + let error = HostValue::decode(&bytes).expect("an error decodes"); assert_eq!(codes(&error), expect, "{what}: the codes it reaches"); error } /// An error's code, then its causes' codes, in order. -fn codes(value: &FfiValue) -> Vec { +fn codes(value: &HostValue) -> Vec { let mut out: Vec = text(get(value, "code")).into_iter().collect(); - if let Some(FfiValue::Array(causes)) = get(value, "causes") { + if let Some(HostValue::List(causes)) = get(value, "causes") { out.extend(causes.iter().filter_map(|cause| text(get(cause, "code")))); } out @@ -71,19 +71,19 @@ fn check(text: &[u8], at: &str, found: &mut Vec) { } } -fn leaks(value: &FfiValue, path: &str, found: &mut Vec) { +fn leaks(value: &HostValue, path: &str, found: &mut Vec) { match value { - FfiValue::String(text) => check(text.risky_ref(), path, found), - FfiValue::Bytes(bytes) => { + HostValue::Text(text) => check(text.as_bytes(), path, found), + HostValue::Bytes(bytes) => { use vitaminc_protected::Controlled; check(bytes.risky_ref(), path, found); } - FfiValue::Array(items) => { + HostValue::List(items) => { for (at, item) in items.iter().enumerate() { leaks(item, &format!("{path}[{at}]"), found); } } - FfiValue::Object(entries) => { + HostValue::Map(entries) => { for (key, item) in entries { check(key.as_bytes(), &format!("{path} key"), found); leaks(item, &format!("{path}.{key}"), found); @@ -93,16 +93,16 @@ fn leaks(value: &FfiValue, path: &str, found: &mut Vec) { } } -fn get<'a>(value: &'a FfiValue, key: &str) -> Option<&'a FfiValue> { +fn get<'a>(value: &'a HostValue, key: &str) -> Option<&'a HostValue> { match value { - FfiValue::Object(entries) => entries.iter().find(|(k, _)| k == key).map(|(_, v)| v), + HostValue::Map(entries) => entries.iter().find(|(k, _)| k == key).map(|(_, v)| v), _ => None, } } -fn text(value: Option<&FfiValue>) -> Option { +fn text(value: Option<&HostValue>) -> Option { match value? { - FfiValue::String(s) => String::from_utf8(s.risky_ref().to_vec()).ok(), + HostValue::Text(s) => Some(s.to_string()), _ => None, } } @@ -111,7 +111,7 @@ fn dir(path: &Path) -> Vec { path.to_str().expect("utf8 temp dir").as_bytes().to_vec() } -fn scenarios() -> Vec<(&'static str, FfiValue)> { +fn scenarios() -> Vec<(&'static str, HostValue)> { let root = tempfile::tempdir().expect("temp dir"); let store = dir(root.path()); let workspace = root.path().join("workspaces").join("AAAAAAAAAAAAAAAA"); @@ -268,7 +268,7 @@ fn a_profile_json_error_points_at_the_line() { Some("stack_profile::json") ); let fields = get(&error, "fields").expect("fields"); - assert!(matches!(get(fields, "line"), Some(FfiValue::UInt64(2)))); + assert!(matches!(get(fields, "line"), Some(HostValue::UInt(2)))); assert!( text(get(&error, "help")).is_some(), "the help says how to fix it" diff --git a/languages/golang/encrypt/ciphertext.go b/languages/golang/encrypt/ciphertext.go index 242d85d67..7d70475d8 100644 --- a/languages/golang/encrypt/ciphertext.go +++ b/languages/golang/encrypt/ciphertext.go @@ -50,8 +50,9 @@ func scanBytes(kind string, src any) ([]byte, error) { } // otherLeaf is a leaf of a kind the SDK does not store: the authenticated -// markers for an absent value, an empty sequence and an empty map, which a -// record field never produces. Decoding one is an error at the use site. +// markers for an empty sequence and an empty map, which a record field never +// produces. Decoding one is an error at the use site. An absent value is a +// sealed null, a Ciphertext like any other value. type otherLeaf struct { kind vcffi.LeafKind bytes []byte diff --git a/languages/golang/encrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock index f9ce384e9..926813680 100644 --- a/languages/golang/encrypt/guest/Cargo.lock +++ b/languages/golang/encrypt/guest/Cargo.lock @@ -484,7 +484,7 @@ dependencies = [ "url", "utoipa", "uuid", - "vitaminc", + "vitaminc 0.5.1", ] [[package]] @@ -709,10 +709,8 @@ dependencies = [ "stack-encrypt", "thiserror 2.0.20", "ts-rs", - "vitaminc-aead", - "vitaminc-aead-value", + "vitaminc-aead 0.6.0", "vitaminc-prf", - "vitaminc-protected", "zeroize", ] @@ -1488,6 +1486,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -2057,8 +2064,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc", - "vitaminc-protected", + "vitaminc 0.6.0", + "vitaminc-protected 0.6.0", "web-time", "zeroize", "zerokms-protocol", @@ -2066,7 +2073,7 @@ dependencies = [ [[package]] name = "stack-encrypt" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64ct", "cllw-ore", @@ -2077,18 +2084,17 @@ dependencies = [ "stack-kms", "thiserror 1.0.69", "uuid", - "vitaminc-aead", - "vitaminc-aead-value", - "vitaminc-encrypt", + "vitaminc-aead 0.6.0", + "vitaminc-encrypt 0.6.0", "vitaminc-hmac", "vitaminc-prf", - "vitaminc-protected", + "vitaminc-protected 0.6.0", "zeroize", ] [[package]] name = "stack-encrypt-derive" -version = "0.2.0" +version = "0.3.0" dependencies = [ "proc-macro2", "quote", @@ -2111,8 +2117,8 @@ dependencies = [ "stack-guest-abi", "stack-kms", "uuid", - "vitaminc-aead-value", - "vitaminc-protected", + "vitaminc-aead-frame", + "vitaminc-protected 0.6.0", "zeroize", "zerokms-protocol", ] @@ -2124,8 +2130,9 @@ dependencies = [ "miette", "serde_json", "thiserror 1.0.69", - "vitaminc-aead-value", - "vitaminc-protected", + "vitaminc-aead 0.6.0", + "vitaminc-aead-frame", + "vitaminc-protected 0.6.0", "zeroize", ] @@ -2151,8 +2158,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc", - "vitaminc-protected", + "vitaminc 0.6.0", + "vitaminc-protected 0.6.0", "zeroize", "zerokms-protocol", ] @@ -2359,6 +2366,36 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2575,12 +2612,26 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ - "vitaminc-aead", - "vitaminc-context", - "vitaminc-encrypt", - "vitaminc-protected", - "vitaminc-random", - "vitaminc-traits", + "vitaminc-aead 0.5.1", + "vitaminc-context 0.5.1", + "vitaminc-encrypt 0.5.1", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "vitaminc-traits 0.5.1", +] + +[[package]] +name = "vitaminc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0c6b8dff9bfc94e076fcd5496395958e6c8dddb27605b6504c54123512c7c8e" +dependencies = [ + "vitaminc-aead 0.6.0", + "vitaminc-context 0.6.0", + "vitaminc-encrypt 0.6.0", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", + "vitaminc-traits 0.6.0", ] [[package]] @@ -2591,10 +2642,27 @@ checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", - "vitaminc-aead-derive", - "vitaminc-context", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-aead-derive 0.5.1", + "vitaminc-context 0.5.1", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-aead" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcea687f745050d80df32c93fbe7f8d18ed2cc5fb5e029814477dfe31449ec2e" +dependencies = [ + "bytes", + "futures-util", + "serde", + "thiserror 2.0.20", + "vitaminc-aead-derive 0.6.0", + "vitaminc-context 0.6.0", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", "zeroize", ] @@ -2610,14 +2678,23 @@ dependencies = [ ] [[package]] -name = "vitaminc-aead-value" -version = "0.5.1" +name = "vitaminc-aead-derive" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d78cdadbc6374c3bd7c19fb4d99abca693b96953b18fef8c608efbe092cf3ea" +checksum = "085afc8c77ffadb4c8a93c9c5fa2d897196a36ab38e17c08fe96f8b9c309e05f" dependencies = [ - "vitaminc-aead", - "vitaminc-protected", - "zeroize", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "vitaminc-aead-frame" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c253055230469c4af4791e902c41ce0ce55069c512249b33cf041dd5c58c517" +dependencies = [ + "vitaminc-aead 0.6.0", ] [[package]] @@ -2627,7 +2704,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", - "vitaminc-protected", + "vitaminc-protected 0.5.1", +] + +[[package]] +name = "vitaminc-context" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4d5679e511871da942ba2b29fcb1df198ee2359218bf7f11cfec4fd3dcfeee0" +dependencies = [ + "mutants", + "vitaminc-protected 0.6.0", ] [[package]] @@ -2638,35 +2725,49 @@ checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-aead 0.5.1", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061d55dc4084fcc2eaa7334dd28300cd8d321038339a41085b7bf2dc6ccf5d4c" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead 0.6.0", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15fe558a5e92d4f2b3f811c5b67d863d9255da5f66d08a71d4f0dd3d0011c563" +checksum = "393354b2348eabce0420626fdc7cf70ac779870f82abb566fb491ea19eeea066" dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected", + "vitaminc-protected 0.6.0", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712d31d46d76b38c6b62deb35f516fc5636a35f90f39c0213d0ca9a052b4400b" +checksum = "11de28e524ab1deecedbb023bd328916b6c023e2f52156521ac1df75a689d911" dependencies = [ "mutants", "thiserror 2.0.20", - "vitaminc-context", - "vitaminc-protected", + "vitaminc-context 0.6.0", + "vitaminc-protected 0.6.0", ] [[package]] @@ -2682,7 +2783,24 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.20", - "vitaminc-protected-derive", + "vitaminc-protected-derive 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-protected" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efe97c67b61fd749b20d84248416a6cc1dc3f024a4695e3f69eee63aee7cb7c2" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.20", + "vitaminc-protected-derive 0.6.0", "zeroize", ] @@ -2697,6 +2815,18 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "vitaminc-protected-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0483ecfcb3e874d45c9b2dc04d8e5ed55ff453023a9de98fddc4a24b315d9f6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "vitaminc-random" version = "0.5.1" @@ -2707,8 +2837,23 @@ dependencies = [ "getrandom 0.4.3", "rand 0.10.2", "thiserror 2.0.20", - "vitaminc-protected", - "vitaminc-random-derives", + "vitaminc-protected 0.5.1", + "vitaminc-random-derives 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-random" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693ae19994e6a79f9839e510220f532daab6f2ab0b4167fdd0d8379a027c653f" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand 0.10.2", + "thiserror 2.0.20", + "vitaminc-protected 0.6.0", + "vitaminc-random-derives 0.6.0", "zeroize", ] @@ -2723,6 +2868,17 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "vitaminc-random-derives" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fb9ad6bb972f7e1b0889eba0a3ed534afe86dd8e52f197a2de20ebe25ee9e56" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "vitaminc-traits" version = "0.5.1" @@ -2734,8 +2890,24 @@ dependencies = [ "rmp-serde", "serde", "thiserror 2.0.20", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-traits" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c9f86c930cb4f98eee9288f463a133f01774be039ba05a092f12148b45c430c" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.20", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", "zeroize", ] @@ -2981,6 +3153,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/languages/golang/encrypt/guest/Cargo.toml b/languages/golang/encrypt/guest/Cargo.toml index ef9814ecf..69f72c4fd 100644 --- a/languages/golang/encrypt/guest/Cargo.toml +++ b/languages/golang/encrypt/guest/Cargo.toml @@ -61,13 +61,13 @@ stack-kms = { path = "../../../../packages/stack-kms", default-features = false eql-bindings = { path = "../../../../packages/eql/crates/eql-bindings", features = ["stack-encrypt"], optional = true } zerokms-protocol = "=0.12.31" -# The FFI codec + `FfiValue` tree, shared with vitaminc's own guest — one -# codec, not a fork. Same vitaminc version stack-encrypt builds against -# (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.1" -vitaminc-protected = "0.5.1" +# The transport frame and ciphertext codec, shared with vitaminc's own +# guest — one codec, not a fork. Same vitaminc version stack-encrypt builds +# against (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-frame = "0.6.0" +vitaminc-protected = "0.6.0" futures = { version = "0.3", default-features = false, features = ["executor"] } serde = "1" diff --git a/languages/golang/encrypt/guest/src/abi.rs b/languages/golang/encrypt/guest/src/abi.rs index 5e5d1c675..7abbdd660 100644 --- a/languages/golang/encrypt/guest/src/abi.rs +++ b/languages/golang/encrypt/guest/src/abi.rs @@ -72,12 +72,11 @@ use std::cell::{Cell, RefCell}; use std::panic::{catch_unwind, AssertUnwindSafe}; use futures::executor::block_on; +use stack_encrypt::dynamic::FfiValue; use stack_encrypt::{KeysetCipher, StackCipher}; use stack_guest_abi::abi::{export, input, take_plaintext, wipe_input}; use stack_guest_abi::buffers; use stack_guest_abi::last_error::{self, malformed, out_of_order}; -use vitaminc_aead_value::transport as codec; -use vitaminc_aead_value::FfiValue; use crate::ops; use crate::options::{parse_options, parse_selector, scope_for, KeysetSelector, Side}; @@ -111,8 +110,8 @@ thread_local! { /// Decode one codec-encoded input. fn decode(bytes: &[u8]) -> Result { - codec::decode_value(&mut codec::Reader::new(bytes)) - .map_err(|_| malformed("an input is not a value in the transport codec")) + FfiValue::from_frame(bytes) + .map_err(|_| malformed("an input is not a value in the transport frame")) } /// Run `f` with the instance's cipher, or report `STATUS_STATE` when there @@ -336,7 +335,7 @@ pub unsafe extern "C" fn se_keyset(sel_ptr: *const u8, sel_len: u32) -> u64 { /// /// The context is one part — a string, bytes, or an `i32`/`i64`/`u32`/`u64` /// — or an array of parts, which may nest as deep as the transport codec -/// allows (`vitaminc_aead_value::transport::MAX_DEPTH` levels, counted from +/// allows (`vitaminc_aead_frame::MAX_DEPTH` levels, counted from /// the root of the encoded value; deeper is refused as `STATUS_ENCODING` /// before the context is parsed). [`stack_encrypt::dynamic::context`] is the one home of /// that grammar and of which Rust context each shape spells. diff --git a/languages/golang/encrypt/guest/src/config.rs b/languages/golang/encrypt/guest/src/config.rs index 800461444..e31f1e7d7 100644 --- a/languages/golang/encrypt/guest/src/config.rs +++ b/languages/golang/encrypt/guest/src/config.rs @@ -25,9 +25,9 @@ use std::num::NonZeroUsize; +use stack_encrypt::dynamic::FfiValue; use stack_kms::{ClientKey, ZeroKmsEndpoint}; use uuid::Uuid; -use vitaminc_aead_value::FfiValue; use zeroize::Zeroizing; /// A parse failure, carrying which key was at fault. Maps to diff --git a/languages/golang/encrypt/guest/src/lib.rs b/languages/golang/encrypt/guest/src/lib.rs index dfe7fbbdb..e402e14d0 100644 --- a/languages/golang/encrypt/guest/src/lib.rs +++ b/languages/golang/encrypt/guest/src/lib.rs @@ -78,8 +78,9 @@ //! //! On wasm32 `vitaminc-encrypt` uses its pure-Rust (RustCrypto `aes-gcm`) //! backend; the trade-offs are documented there. Values cross the boundary -//! in the vitaminc FFI codec (`vitaminc_aead_value::transport` — an FFI -//! encoding, not a storage format); the leaves inside a ciphertext tree are +//! as vitaminc transport frames and ciphertext trees in vitaminc's +//! ciphertext codec (`vitaminc_aead_frame` — FFI encodings, not a storage +//! format); the leaves inside a ciphertext tree are //! the *frozen* `SealedValue` byte encoding from Phase 2, so a leaf lifted //! out of a tree is exactly what a database column holds. diff --git a/languages/golang/encrypt/guest/src/ops.rs b/languages/golang/encrypt/guest/src/ops.rs index c33466ee7..a20f36286 100644 --- a/languages/golang/encrypt/guest/src/ops.rs +++ b/languages/golang/encrypt/guest/src/ops.rs @@ -4,9 +4,10 @@ //! module wires them to the session table and the packed ABI; nothing in //! here knows about linear memory. //! -//! Values and ciphertext trees cross the boundary in the vitaminc FFI codec -//! (`vitaminc_aead_value::transport`) — the same codec the vitaminc guest -//! uses, so the Go side carries exactly one codec. A ciphertext tree's +//! Values cross the boundary as vitaminc transport frames, and ciphertext +//! trees in vitaminc's ciphertext codec (`vitaminc_aead_frame`) — the same +//! encodings the vitaminc guest uses, so the Go side carries exactly one +//! codec. A ciphertext tree's //! leaves are re-encoded through [`SealedValue::to_bytes`] / //! [`SealedValue::from_bytes`]: the codec sees an opaque byte-string leaf, //! and the bytes inside it are the frozen storage encoding a database column @@ -35,16 +36,15 @@ //! ABI's numeric term kinds, and the mapping from a library error to a //! status code. -use stack_encrypt::dynamic::{self, Scalar, Scope}; +use stack_encrypt::dynamic::{self, FfiValue, Scalar, Scope}; use stack_encrypt::sem::MatchOptions; use stack_encrypt::target::IndexSpec; use stack_encrypt::ErrorPayload; -use stack_encrypt::{ - BoxedPassthrough, CipherText, KeysetCipher, Label, SealedValue, StackCipherText, -}; +use stack_encrypt::{CipherText, Encrypt, KeysetCipher, Label, Node, SealedValue, StackCipherText}; use stack_kms::DataKeySource; -use vitaminc_aead_value::{transport as codec, FfiValue}; +use vitaminc_aead_frame::{decode_ciphertext, encode_ciphertext, FrameWriter}; use vitaminc_protected::Controlled; +use zeroize::Zeroizing; use stack_guest_abi::last_error; @@ -63,7 +63,7 @@ pub const TERM_OPE: u32 = 4; /// A ciphertext tree whose leaves are the frozen [`SealedValue`] byte /// encoding — the shape that crosses the FFI codec. -type BytesTree = CipherText, BoxedPassthrough>; +type BytesTree = Node>; // ============================================================================= // Terms @@ -78,7 +78,7 @@ type BytesTree = CipherText, BoxedPassthrough>; /// /// The context is one part — a string, bytes, or an `i32`/`i64`/`u32`/`u64` /// — or an array of parts, nested as deep as the transport codec allows -/// ([`codec::MAX_DEPTH`] levels from the root of the encoded value; deeper +/// ([`MAX_DEPTH`](vitaminc_aead_frame::MAX_DEPTH) levels from the root of the encoded value; deeper /// is [`STATUS_ENCODING`] before the context is parsed), exactly as a plan /// field's; [`dynamic::context`] is the one home of that grammar. Shape is identity: /// `[x]` is a PAE-framed list and `x` is not, so a probe takes the context @@ -374,31 +374,34 @@ pub mod validate { // ============================================================================= fn decode_value(bytes: &[u8]) -> Result { - codec::decode_value(&mut codec::Reader::new(bytes)) - .map_err(|_| last_error::malformed("an input is not a value in the transport codec")) + FfiValue::from_frame(bytes) + .map_err(|_| last_error::malformed("an input is not a value in the transport frame")) } -/// Encode a value tree into a buffer sized **before** the first byte is -/// written. +/// Encode a value tree into a buffer whose capacity is exactly its length. /// -/// This buffer is plaintext on the decrypt path, and a `Vec` grown by the -/// codec's pushes would leave partial plaintext in every abandoned -/// allocation a reallocation could not extend in place — memory nothing -/// wipes, undercutting the guarantee the registry makes about the buffer it -/// eventually hands the host. Reserving the exact encoded length up front -/// means the encoder never reallocates, and `register`'s `into_boxed_slice` -/// (capacity == length) does not copy either. +/// This buffer is plaintext on the decrypt path, and the registry wipes only +/// the buffer it holds: `register`'s `into_boxed_slice` reallocates a `Vec` +/// whose capacity exceeds its length, leaving the old allocation unwiped. +/// The frame writer reserves an upper bound per leaf (a six-byte string +/// reserves 14 for 11), so writing straight into the exactly sized buffer +/// can still grow it. It writes into a `Zeroizing` staging buffer instead +/// (the writer wipes any allocation it replaces), which is copied into an +/// exactly sized output and then wiped. fn encode_value(value: FfiValue) -> Result, u32> { - let mut out = exact_buffer(value_encoded_len(&value))?; - codec::encode_value(value, &mut out).map_err(|_| STATUS_ENCODING)?; + let mut staging = Zeroizing::new(exact_buffer(value_encoded_len(&value))?); + (&value) + .visit(FrameWriter::new(&mut staging)) + .map_err(|_| STATUS_ENCODING)?; + let mut out = exact_buffer(Some(staging.len()))?; + out.extend_from_slice(&staging); Ok(out) } fn decode_tree(bytes: &[u8]) -> Result { - let tree: BytesTree = - codec::decode_ciphertext_boxed(&mut codec::Reader::new(bytes)).map_err(|_| { - last_error::malformed("the record is not a ciphertext tree in the transport codec") - })?; + let tree: BytesTree = decode_ciphertext(bytes).map_err(|_| { + last_error::malformed("the record is not a ciphertext tree in the transport codec") + })?; // Structural only — a decoded leaf proves nothing until its AEAD opens // (see the `SealedValue` docs). map_leaves(tree, &mut |l: Vec| { @@ -413,20 +416,19 @@ fn decode_tree(bytes: &[u8]) -> Result { fn encode_tree(tree: StackCipherText) -> Result, u32> { let tree = map_leaves(tree, &mut |l: SealedValue| Ok::<_, u32>(l.to_bytes()))?; let mut out = exact_buffer(tree_encoded_len(&tree))?; - codec::encode_ciphertext_boxed(tree, &mut out).map_err(|_| STATUS_ENCODING)?; + encode_ciphertext(&tree, &mut out).map_err(|_| STATUS_ENCODING)?; Ok(out) } /// Rebuild a ciphertext tree with every leaf run through `leaf`, keeping the -/// structure (and the passthrough payloads) untouched. One definition for +/// structure (and the passthrough frames) untouched. One definition for /// both directions of the frozen [`SealedValue`] leaf encoding. fn map_leaves( - tree: CipherText, + tree: Node, leaf: &mut impl FnMut(A) -> Result, -) -> Result, E> { +) -> Result, E> { Ok(match tree { CipherText::Single(l) => CipherText::Single(leaf(l)?), - CipherText::None(l) => CipherText::None(leaf(l)?), CipherText::EmptySequence(l) => CipherText::EmptySequence(leaf(l)?), CipherText::EmptyMap(l) => CipherText::EmptyMap(leaf(l)?), CipherText::Sequence(items) => CipherText::Sequence( @@ -460,9 +462,9 @@ fn exact_buffer(len: Option) -> Result, u32> { Ok(out) } -/// Exact byte length of the codec's encoding of `value`. `None` on overflow -/// or on a length the codec's `u32` frames cannot express — the encode would -/// fail on those anyway, so the caller reports an encoding error. +/// Exact byte length of the frame of `value`. `None` on overflow or on a +/// length the frame's `u32` lengths cannot express — the encode would fail +/// on those anyway, so the caller reports an encoding error. fn value_encoded_len(value: &FfiValue) -> Option { // tag byte + fixed payload, or tag + u32 length prefix + payload. let framed = |len: usize| u32::try_from(len).ok().and_then(|_| len.checked_add(5)); @@ -483,18 +485,14 @@ fn value_encoded_len(value: &FfiValue) -> Option { } } -/// Exact byte length of the codec's encoding of a ciphertext tree. The -/// passthrough payloads are read (not consumed) through `Any::downcast_ref`, -/// matching what `encode_ciphertext_boxed` will re-home them to; a payload -/// that is not an [`FfiValue`] is `None`, which is the same rejection the -/// encoder would make. +/// Exact byte length of the codec's encoding of a ciphertext tree. A +/// passthrough is its node byte and its frame, written as it is. fn tree_encoded_len(tree: &BytesTree) -> Option { let framed = |len: usize| u32::try_from(len).ok().and_then(|_| len.checked_add(5)); match tree { - CipherText::Single(l) - | CipherText::None(l) - | CipherText::EmptySequence(l) - | CipherText::EmptyMap(l) => framed(l.len()), + CipherText::Single(l) | CipherText::EmptySequence(l) | CipherText::EmptyMap(l) => { + framed(l.len()) + } CipherText::Sequence(items) => items .iter() .try_fold(5usize, |acc, item| acc.checked_add(tree_encoded_len(item)?)), @@ -502,9 +500,7 @@ fn tree_encoded_len(tree: &BytesTree) -> Option { acc.checked_add(framed(key.len())?.checked_sub(1)?)? .checked_add(tree_encoded_len(value)?) }), - CipherText::Passthrough(p) => { - value_encoded_len((**p).downcast_ref::()?)?.checked_add(1) - } + CipherText::Passthrough(p) => p.0.len().checked_add(1), } } @@ -513,8 +509,8 @@ mod tests { use super::*; use vitaminc_protected::Protected; - // `value_encoded_len` / `tree_encoded_len` re-derive the codec's framing - // arithmetic; the codec exports no `encoded_len` of its own, so these + // `value_encoded_len` / `tree_encoded_len` re-derive the frame's and the + // codec's arithmetic; neither exports an `encoded_len` of its own, so these // pins are the only thing that fails if the two drift. Drift is not a // cosmetic bug: an undersized reservation makes `encode_value` // reallocate mid-encode, leaving unwiped partial plaintext in the @@ -552,13 +548,24 @@ mod tests { ] } + /// The registry wipes the buffer it is handed, and `into_boxed_slice` + /// reallocates one whose capacity exceeds its length, leaving the old + /// allocation unwiped. So every encoded output's capacity is its length, + /// not only its length the frame's. + #[test] + fn an_encoded_value_has_no_spare_capacity() { + for (i, value) in every_value_shape().into_iter().enumerate() { + let out = encode_value(value).expect("encodes"); + assert_eq!(out.capacity(), out.len(), "shape {i}"); + } + } + #[test] fn value_encoded_len_matches_the_codec_exactly() { for (i, value) in every_value_shape().into_iter().enumerate() { let expected = value_encoded_len(&value).expect("encodable shape"); - let mut out = Vec::new(); - codec::encode_value(value, &mut out).expect("codec encode"); - assert_eq!(out.len(), expected, "shape {i}"); + let out = value.to_frame().expect("frame encode"); + assert_eq!(out.risky_ref().len(), expected, "shape {i}"); } } @@ -568,10 +575,9 @@ mod tests { let trees: Vec = vec![ leaf(b""), leaf(&[7u8; 40]), - CipherText::None(vec![1, 2]), CipherText::EmptySequence(vec![3]), CipherText::EmptyMap(Vec::new()), - CipherText::Sequence(vec![leaf(b"a"), CipherText::None(vec![9])]), + CipherText::Sequence(vec![leaf(b"a"), CipherText::EmptyMap(vec![9])]), CipherText::Map(vec![ ("name".to_string(), leaf(b"ct")), ( @@ -579,14 +585,17 @@ mod tests { CipherText::Map(vec![("x".to_string(), leaf(b"y"))]), ), ]), - CipherText::Passthrough( - Box::new(FfiValue::Bytes(Protected::new(vec![1, 2, 3]))) as BoxedPassthrough - ), + CipherText::Passthrough(stack_encrypt::PassthroughFrame( + FfiValue::Bytes(Protected::new(vec![1, 2, 3])) + .to_frame() + .expect("a frame") + .risky_unwrap(), + )), ]; for (i, tree) in trees.into_iter().enumerate() { let expected = tree_encoded_len(&tree).expect("encodable shape"); let mut out = Vec::new(); - codec::encode_ciphertext_boxed(tree, &mut out).expect("codec encode"); + encode_ciphertext(&tree, &mut out).expect("codec encode"); assert_eq!(out.len(), expected, "tree {i}"); } } @@ -594,9 +603,7 @@ mod tests { // ---- the EQL target path, native, over FakeDataKeySource ----------------- fn encoded(value: FfiValue) -> Vec { - let mut out = Vec::new(); - codec::encode_value(value, &mut out).expect("encode"); - out + value.to_frame().expect("encode").risky_unwrap() } fn text(value: &str) -> FfiValue { @@ -749,7 +756,7 @@ mod tests { let CipherText::Passthrough(payload) = &outputs[0].1 else { panic!("a passthrough") }; - let FfiValue::Bytes(bytes) = payload.downcast_ref::().expect("a value") else { + let FfiValue::Bytes(bytes) = FfiValue::from_frame(&payload.0).expect("a value") else { panic!("bytes") }; let eql: serde_json::Value = serde_json::from_slice(bytes.risky_ref()).expect("JSON"); diff --git a/languages/golang/encrypt/guest/src/options.rs b/languages/golang/encrypt/guest/src/options.rs index 02db276e1..569b4d05c 100644 --- a/languages/golang/encrypt/guest/src/options.rs +++ b/languages/golang/encrypt/guest/src/options.rs @@ -41,11 +41,11 @@ //! it is objects, strings, bytes and nothing else, and this module is its //! one home. The Go bindings plan points here. +use stack_encrypt::dynamic::FfiValue; use stack_encrypt::dynamic::Scope; use stack_encrypt::{KeysetCipher, Label, StackCipher}; use stack_kms::{IdentifiedBy, IndexKeySource}; use uuid::Uuid; -use vitaminc_aead_value::FfiValue; use vitaminc_protected::Controlled; use zerokms_protocol::Name; diff --git a/languages/golang/encrypt/guest/src/status.rs b/languages/golang/encrypt/guest/src/status.rs index 8425272e4..649ecddc8 100644 --- a/languages/golang/encrypt/guest/src/status.rs +++ b/languages/golang/encrypt/guest/src/status.rs @@ -414,8 +414,8 @@ mod tests { /// such. Swapping the arms, or dropping the `Other` one, fails here. #[test] fn target_refusals_are_encoding_and_a_resolver_failure_is_internal() { + use stack_encrypt::dynamic::ValueKind; use stack_encrypt::dynamic::{Error, TargetError}; - use vitaminc_aead_value::ValueKind; let refusals = [ TargetError::NoTargets { name: "TextEq".into(), diff --git a/languages/golang/encrypt/guest/src/targets.rs b/languages/golang/encrypt/guest/src/targets.rs index 3451b9176..be15da540 100644 --- a/languages/golang/encrypt/guest/src/targets.rs +++ b/languages/golang/encrypt/guest/src/targets.rs @@ -60,7 +60,7 @@ impl TargetResolver for EqlTargets { name: &str, keyset: &'a stack_encrypt::KeysetCipher<'_, K>, label: &stack_encrypt::Label, - plaintext: vitaminc_aead_value::FfiValue, + plaintext: stack_encrypt::dynamic::FfiValue, ) -> Result, K>, TargetError> { eql_bindings::encryption::targets::encrypt(name, keyset, label, plaintext).map_err(convert) } @@ -71,7 +71,7 @@ impl TargetResolver for EqlTargets { cipher: &'a stack_encrypt::StackCipher, label: &stack_encrypt::Label, stored: &[u8], - ) -> Result, TargetError> { + ) -> Result, TargetError> { eql_bindings::encryption::targets::decrypt(name, cipher, label, stored).map_err(convert) } @@ -80,7 +80,7 @@ impl TargetResolver for EqlTargets { name: &str, keyset: &'a stack_encrypt::KeysetCipher<'_, K>, label: &stack_encrypt::Label, - plaintext: vitaminc_aead_value::FfiValue, + plaintext: stack_encrypt::dynamic::FfiValue, ) -> Result, K>, TargetError> { eql_bindings::encryption::targets::query(name, keyset, label, plaintext).map_err(convert) } @@ -169,7 +169,7 @@ mod tests { assert!(text_eq.producible); assert_eq!( text_eq.plaintext, - Some(vitaminc_aead_value::ValueKind::String) + Some(stack_encrypt::dynamic::ValueKind::String) ); assert_eq!(text_eq.indexes, ["eq"]); assert_eq!(text_eq.query.as_deref(), Some("TextEqQuery")); @@ -216,10 +216,10 @@ mod tests { assert!(matches!( convert(Eql::Plaintext { target: "TextEq", - expected: vitaminc_aead_value::ValueKind::String, - found: Some(vitaminc_aead_value::ValueKind::UInt64), + expected: stack_encrypt::dynamic::ValueKind::String, + found: Some(stack_encrypt::dynamic::ValueKind::UInt64), }), - TargetError::Plaintext { target, expected: Some(vitaminc_aead_value::ValueKind::String), .. } if target == "TextEq" + TargetError::Plaintext { target, expected: Some(stack_encrypt::dynamic::ValueKind::String), .. } if target == "TextEq" )); } diff --git a/languages/golang/encrypt/guest/tests/leak.rs b/languages/golang/encrypt/guest/tests/leak.rs index 286520f51..46cb0816d 100644 --- a/languages/golang/encrypt/guest/tests/leak.rs +++ b/languages/golang/encrypt/guest/tests/leak.rs @@ -21,8 +21,9 @@ use std::borrow::Cow; use std::future::IntoFuture; use stack_auth::{SecretToken, ServiceToken}; +use stack_encrypt::dynamic::FfiValue; use stack_encrypt::dynamic::Scope; -use stack_encrypt::{CipherText, Descriptor, StackCipher}; +use stack_encrypt::{CipherText, Descriptor, Node, PassthroughFrame, StackCipher}; use stack_encrypt_guest::options::{parse_options, Side}; use stack_encrypt_guest::status::fail_error; use stack_encrypt_guest::{config, ops}; @@ -32,7 +33,7 @@ use stack_kms::{ GenerateKeyPayload, IndexKeySource, LoadKeysetError, RetrieveKeyError, RetrieveKeyPayload, }; use uuid::Uuid; -use vitaminc_aead_value::{transport as codec, FfiValue}; +use vitaminc_aead_frame::encode_ciphertext; use zerokms_protocol::{IdentifiedBy, UnverifiedContext, ViturRequestError}; const PLAINTEXT: &str = "leak-marker-plaintext"; @@ -127,9 +128,13 @@ fn failing() -> StackCipher { // ============================================================================= fn encode(value: FfiValue) -> Vec { - let mut out = Vec::new(); - codec::encode_value(value, &mut out).expect("encode"); - out + use vitaminc_protected::Controlled; + value.to_frame().expect("encode").risky_unwrap() +} + +/// A value as a passthrough node: its frame, in the clear. +fn clear(value: FfiValue) -> Node> { + CipherText::Passthrough(PassthroughFrame(encode(value))) } fn s(value: &str) -> FfiValue { @@ -191,13 +196,13 @@ fn sealed(cipher: &StackCipher, plan: &[u8]) -> Vec { } /// A one-leaf ciphertext tree whose leaf is `leaf`, under `email`/`c`. -fn tree_with_leaf(leaf: CipherText, Box>) -> Vec { - let tree: CipherText, Box> = CipherText::Map(vec![( +fn tree_with_leaf(leaf: Node>) -> Vec { + let tree: Node> = CipherText::Map(vec![( "email".to_owned(), CipherText::Map(vec![("c".to_owned(), leaf)]), )]); let mut out = Vec::new(); - codec::encode_ciphertext_boxed(tree, &mut out).expect("encode tree"); + encode_ciphertext(&tree, &mut out).expect("encode tree"); out } @@ -218,7 +223,7 @@ fn failure( ) -> FfiValue { let _ = call::run(step).expect_err(what); let bytes = call::take_last_error().unwrap_or_else(|| panic!("{what}: no error recorded")); - let error = codec::decode_value(&mut codec::Reader::new(&bytes)).expect("an error decodes"); + let error = FfiValue::from_frame(&bytes).expect("an error decodes"); assert_eq!(codes(&error), expect, "{what}: the codes it reaches"); error } @@ -488,8 +493,8 @@ fn scenarios() -> Vec<(&'static str, FfiValue)> { "record passthrough", &["stack_encrypt::dynamic_record"], || { - let record = tree_with_leaf(CipherText::Passthrough(Box::new( - FfiValue::Bytes(vitaminc_protected::Protected::new(CIPHERTEXT.to_vec())), + let record = tree_with_leaf(clear(FfiValue::Bytes( + vitaminc_protected::Protected::new(CIPHERTEXT.to_vec()), ))); block_on(ops::decrypt_record( Scope::Client(&cipher), @@ -651,19 +656,17 @@ fn scenarios() -> Vec<(&'static str, FfiValue)> { "email", obj(vec![("context", label("email")), ("target", s("TextEq"))]), )])); - let tree: CipherText, Box> = CipherText::Map(vec![( + let tree: Node> = CipherText::Map(vec![( "email".to_owned(), CipherText::Map(vec![( "eql".to_owned(), - CipherText::Passthrough(Box::new(FfiValue::Bytes( - vitaminc_protected::Protected::new( - [&b"\""[..], CIPHERTEXT, &b"\""[..]].concat(), - ), + clear(FfiValue::Bytes(vitaminc_protected::Protected::new( + [&b"\""[..], CIPHERTEXT, &b"\""[..]].concat(), ))), )]), )]); let mut record = Vec::new(); - codec::encode_ciphertext_boxed(tree, &mut record).expect("encode tree"); + encode_ciphertext(&tree, &mut record).expect("encode tree"); block_on(ops::decrypt_record( Scope::Client(&cipher), &record, diff --git a/languages/golang/encrypt/guest/tests/native_ops.rs b/languages/golang/encrypt/guest/tests/native_ops.rs index 0e653f452..bcd86402d 100644 --- a/languages/golang/encrypt/guest/tests/native_ops.rs +++ b/languages/golang/encrypt/guest/tests/native_ops.rs @@ -17,9 +17,13 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::future::IntoFuture; +use stack_encrypt::dynamic::FfiValue; use stack_encrypt::dynamic::Scope; use stack_encrypt::sem::DefaultMatch; -use stack_encrypt::{nonempty, CipherText, Encrypt, Label, SealedValue, StackCipher}; +use stack_encrypt::{ + nonempty, CipherText, Label, Node, PassthroughFrame, PendingStackCipherText, SealedValue, + StackCipher, +}; use stack_encrypt_guest::ops::{self, TERM_EQUALITY, TERM_MATCH, TERM_OPE, TERM_ORE}; use stack_encrypt_guest::status::{ STATUS_AUTH, STATUS_CONTEXT_MISMATCH, STATUS_ENCODING, STATUS_FOREIGN_KEYSET, @@ -29,7 +33,7 @@ use stack_kms::{ RetrieveKeyPayload, }; use uuid::Uuid; -use vitaminc_aead_value::{transport as codec, FfiValue}; +use vitaminc_aead_frame::{decode_ciphertext, encode_ciphertext}; use vitaminc_protected::{Controlled, Protected}; use zerokms_protocol::{IdentifiedBy, UnverifiedContext}; @@ -93,19 +97,29 @@ fn cipher() -> StackCipher { } fn encode(value: FfiValue) -> Vec { - let mut out = Vec::new(); - codec::encode_value(value, &mut out).expect("encode value"); - out + value.to_frame().expect("encode value").risky_unwrap() } fn decode(bytes: &[u8]) -> FfiValue { - codec::decode_value(&mut codec::Reader::new(bytes)).expect("decode value") + FfiValue::from_frame(bytes).expect("decode value") } -/// Decode a guest ciphertext buffer with the passthrough payload kept as a -/// plain [`FfiValue`], so tests can inspect term nodes directly. -fn decode_tree(bytes: &[u8]) -> CipherText, FfiValue> { - codec::decode_ciphertext(&mut codec::Reader::new(bytes)).expect("decode ciphertext tree") +/// Decode a guest ciphertext buffer with its leaves as bytes, so tests can +/// inspect the nodes directly. +fn decode_tree(bytes: &[u8]) -> Node> { + decode_ciphertext(bytes).expect("decode ciphertext tree") +} + +/// Encode a ciphertext tree as the guest reads it. +fn encode_tree(tree: &Node>) -> Vec { + let mut out = Vec::new(); + encode_ciphertext(tree, &mut out).expect("encode ciphertext tree"); + out +} + +/// A value as a passthrough node: its frame, in the clear. +fn clear(value: FfiValue) -> Node> { + CipherText::Passthrough(PassthroughFrame(encode(value))) } fn text(value: &FfiValue) -> &str { @@ -194,8 +208,11 @@ fn single_field_plan(field: &str, context: FfiValue) -> Vec { } /// The bytes of a term node in a decoded record tree. -fn term_bytes(node: &CipherText, FfiValue>) -> Vec { - let CipherText::Passthrough(FfiValue::Bytes(b)) = node else { +fn term_bytes(node: &Node>) -> Vec { + let CipherText::Passthrough(frame) = node else { + panic!("expected a passthrough term node"); + }; + let FfiValue::Bytes(b) = decode(&frame.0) else { panic!("expected a passthrough bytes term node"); }; b.risky_ref().to_vec() @@ -456,12 +473,11 @@ fn a_forged_passthrough_ciphertext_slot_is_rejected_not_decrypted() { }; for (key, slot) in outputs.iter_mut() { if key == "c" { - *slot = CipherText::Passthrough(FfiValue::UInt32(99)); + *slot = clear(FfiValue::UInt32(99)); } } } - let mut forged = Vec::new(); - codec::encode_ciphertext(&CipherText::Map(fields), &mut forged).expect("re-encode"); + let forged = encode_tree(&CipherText::Map(fields)); assert_eq!( block_on(ops::decrypt_record( @@ -666,9 +682,8 @@ fn a_natively_sealed_field_under_an_extended_context_opens_through_a_plan() { let keyset = cipher.default_keyset(); let native = nonempty!("users").with("age").with(7u64); let sealed = block_on( - FfiValue::UInt32(34) - .encrypt_with_aad(&keyset, native) - .expect("encrypt") + PendingStackCipherText::plan(FfiValue::UInt32(34), native) + .expect("plan") .seal(&keyset, native), ) .expect("seal"); @@ -677,12 +692,11 @@ fn a_natively_sealed_field_under_an_extended_context_opens_through_a_plan() { }; // Shape the tree the way `encrypt_record` writes it: field → { c: leaf }. - let tree: CipherText, FfiValue> = CipherText::Map(vec![( + let tree: Node> = CipherText::Map(vec![( "age".to_string(), CipherText::Map(vec![("c".to_string(), CipherText::Single(leaf.to_bytes()))]), )]); - let mut record = Vec::new(); - codec::encode_ciphertext(&tree, &mut record).expect("encode tree"); + let record = encode_tree(&tree); let plan_with = |context: FfiValue| single_field_plan("age", context); @@ -954,21 +968,15 @@ fn a_mixed_keyset_record_batch_opens_through_any_one_call_per_keyset() { )) .expect("encrypt globex row"); let batch = { - let (CipherText::Map(a), CipherText::Map(g)) = ( - codec::decode_ciphertext_boxed::>(&mut codec::Reader::new(&acme_rows)) - .expect("decode"), - codec::decode_ciphertext_boxed::>(&mut codec::Reader::new(&globex_rows)) - .expect("decode"), - ) else { + let (CipherText::Map(a), CipherText::Map(g)) = + (decode_tree(&acme_rows), decode_tree(&globex_rows)) + else { panic!("a single record is a map"); }; - let mut out = Vec::new(); - codec::encode_ciphertext_boxed( - CipherText::Sequence(vec![CipherText::Map(a), CipherText::Map(g)]), - &mut out, - ) - .expect("encode batch"); - out + encode_tree(&CipherText::Sequence(vec![ + CipherText::Map(a), + CipherText::Map(g), + ])) }; let before = cipher.kms().retrieve_calls.load(Ordering::SeqCst); @@ -1198,7 +1206,7 @@ fn record_tree_validation_refuses_what_decrypt_record_refuses() { .expect("encrypt record"); assert_eq!(ops::validate::record_tree(&record, &plan, None), Ok(())); - type Node = CipherText, FfiValue>; + type Node = stack_encrypt::Node>; // The tree is not `Clone`; every variant decodes the record afresh. let fields = || { let CipherText::Map(fields) = decode_tree(&record) else { @@ -1206,11 +1214,7 @@ fn record_tree_validation_refuses_what_decrypt_record_refuses() { }; fields }; - let re_encode = |tree: Node| { - let mut out = Vec::new(); - codec::encode_ciphertext(&tree, &mut out).expect("re-encode"); - out - }; + let re_encode = |tree: Node| encode_tree(&tree); let with_age = |edit: &dyn Fn(&mut Node)| { let mut fields = fields(); for (field, node) in &mut fields { @@ -1227,7 +1231,7 @@ fn record_tree_validation_refuses_what_decrypt_record_refuses() { }; for (key, slot) in outputs.iter_mut() { if key == "c" { - *slot = CipherText::Passthrough(FfiValue::UInt32(99)); + *slot = clear(FfiValue::UInt32(99)); } } }); @@ -1237,16 +1241,14 @@ fn record_tree_validation_refuses_what_decrypt_record_refuses() { }; outputs.retain(|(key, _)| key != "c"); }); - let not_a_map = with_age(&|node| *node = CipherText::Passthrough(FfiValue::Null)); + let not_a_map = with_age(&|node| *node = clear(FfiValue::Null)); let missing_field = { let mut fields = fields(); fields.retain(|(field, _)| field != "age"); CipherText::Map(fields) }; - let batch_of_non_records = CipherText::Sequence(vec![ - CipherText::Map(fields()), - CipherText::Passthrough(FfiValue::Null), - ]); + let batch_of_non_records = + CipherText::Sequence(vec![CipherText::Map(fields()), clear(FfiValue::Null)]); for (label, tree) in [ ("a forged passthrough under c", forged_c), @@ -1436,7 +1438,7 @@ fn a_context_field_record_opens_under_the_context_it_stores() { let CipherText::Sequence(rows) = decode_tree(&record) else { panic!("a batch is a sequence"); }; - let eq_of = |row: &CipherText, FfiValue>| { + let eq_of = |row: &Node>| { let CipherText::Map(fields) = row else { panic!("a row is a map"); }; diff --git a/languages/golang/encrypt/unit_test.go b/languages/golang/encrypt/unit_test.go index d016d8e6b..23083a74f 100644 --- a/languages/golang/encrypt/unit_test.go +++ b/languages/golang/encrypt/unit_test.go @@ -150,7 +150,6 @@ func TestTermsAndLeavesScanAndValue(t *testing.T) { func TestLeafSetKeepsStackEncryptLeavesDistinct(t *testing.T) { ct := map[string]any{ "a": Ciphertext{1}, - "b": otherLeaf{kind: vcffi.LeafNone, bytes: []byte{2}}, "c": otherLeaf{kind: vcffi.LeafEmptySeq, bytes: []byte{3}}, "d": otherLeaf{kind: vcffi.LeafEmptyMap, bytes: []byte{4}}, "p": vcvalue.Plain{V: "clear"}, diff --git a/languages/golang/go.mod b/languages/golang/go.mod index 751d6f869..0d4e06ecd 100644 --- a/languages/golang/go.mod +++ b/languages/golang/go.mod @@ -3,8 +3,8 @@ module github.com/cipherstash/stack/languages/golang go 1.26.0 require ( - github.com/cipherstash/vitaminc/bindings/go/vcffi v0.0.0-20260902024806-f2c7f7d17fbc - github.com/cipherstash/vitaminc/bindings/go/vcvalue v0.0.0-20260902024806-f2c7f7d17fbc + github.com/cipherstash/vitaminc/bindings/go/vcffi v0.0.0-20261017042657-ca2298d3e7e8 + github.com/cipherstash/vitaminc/bindings/go/vcvalue v0.0.0-20261017042657-ca2298d3e7e8 github.com/tetratelabs/wazero v1.12.0 golang.org/x/oauth2 v0.37.0 ) diff --git a/languages/golang/go.sum b/languages/golang/go.sum index d9d2a09ce..c2e15302b 100644 --- a/languages/golang/go.sum +++ b/languages/golang/go.sum @@ -1,7 +1,7 @@ -github.com/cipherstash/vitaminc/bindings/go/vcffi v0.0.0-20260902024806-f2c7f7d17fbc h1:iGbTgv9Kx5SaTI2tPxaxwDvS5BXZUO565N00cxRVwC8= -github.com/cipherstash/vitaminc/bindings/go/vcffi v0.0.0-20260902024806-f2c7f7d17fbc/go.mod h1:6jpaqAo6f7rjWuxoti0Ymoi9DQZyxpnieWVudhXv38Y= -github.com/cipherstash/vitaminc/bindings/go/vcvalue v0.0.0-20260902024806-f2c7f7d17fbc h1:vlrjoILAURGfpBWucVZEywK22k4lfky1xGDIKV6kqNg= -github.com/cipherstash/vitaminc/bindings/go/vcvalue v0.0.0-20260902024806-f2c7f7d17fbc/go.mod h1:RJODA1DCSm4H+1pbmCzDdT2pAIkxhinwIu6ewjh5sPs= +github.com/cipherstash/vitaminc/bindings/go/vcffi v0.0.0-20261017042657-ca2298d3e7e8 h1:kXpXvBsE5P35aw8yN8l2k3ARuoPdjQkXvxocg4D2WUc= +github.com/cipherstash/vitaminc/bindings/go/vcffi v0.0.0-20261017042657-ca2298d3e7e8/go.mod h1:6jpaqAo6f7rjWuxoti0Ymoi9DQZyxpnieWVudhXv38Y= +github.com/cipherstash/vitaminc/bindings/go/vcvalue v0.0.0-20261017042657-ca2298d3e7e8 h1:72kmUdUUZENerHctDhFMD31/mWtE80cRbpjLPZOeLCs= +github.com/cipherstash/vitaminc/bindings/go/vcvalue v0.0.0-20261017042657-ca2298d3e7e8/go.mod h1:RJODA1DCSm4H+1pbmCzDdT2pAIkxhinwIu6ewjh5sPs= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= diff --git a/languages/golang/stashgen/emit.go b/languages/golang/stashgen/emit.go index 1cd90257b..f72ea02b3 100644 --- a/languages/golang/stashgen/emit.go +++ b/languages/golang/stashgen/emit.go @@ -208,9 +208,10 @@ func (w *writer) shapeStruct(name string, fields []shapeField) { } // kindExpr is the gensupport kind of a Go type: the wire type the field -// seals as. The mapping is vcffi's: int8, int16 and int32 travel as int32, -// int and int64 as int64, the unsigned ones likewise, []byte as bytes. A -// composite is untyped and seals as one self-describing value. +// seals as. int8, int16 and int32 seal as int32, int and int64 as int64, the +// unsigned ones likewise, []byte as bytes: vcffi sends a narrower integer at +// its own width, and the engine reads it as the 32-bit integer of its sign. +// A composite is untyped and seals as one self-describing value. func kindExpr(t GoType) string { switch t.Kind { case KindString: diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index ee6e0d103..9ab983cb6 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -1204,10 +1204,8 @@ dependencies = [ "stack-encrypt", "thiserror 2.0.20", "ts-rs", - "vitaminc-aead 0.5.1", - "vitaminc-aead-value", + "vitaminc-aead 0.6.0", "vitaminc-prf", - "vitaminc-protected 0.5.1", "zeroize", ] @@ -1248,7 +1246,7 @@ dependencies = [ "stack-kms", "tokio", "uuid", - "vitaminc-aead-value", + "vitaminc-aead 0.6.0", ] [[package]] @@ -4221,8 +4219,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.5.1", - "vitaminc-protected 0.5.1", + "vitaminc 0.6.0", + "vitaminc-protected 0.6.0", "web-time", "zeroize", "zerokms-protocol", @@ -4230,7 +4228,7 @@ dependencies = [ [[package]] name = "stack-encrypt" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64ct", "cllw-ore 0.5.0", @@ -4241,17 +4239,17 @@ dependencies = [ "stack-kms", "thiserror 1.0.69", "uuid", - "vitaminc-aead 0.5.1", - "vitaminc-encrypt 0.5.1", + "vitaminc-aead 0.6.0", + "vitaminc-encrypt 0.6.0", "vitaminc-hmac", "vitaminc-prf", - "vitaminc-protected 0.5.1", + "vitaminc-protected 0.6.0", "zeroize", ] [[package]] name = "stack-encrypt-derive" -version = "0.2.0" +version = "0.3.0" dependencies = [ "proc-macro2", "quote", @@ -4280,8 +4278,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.5.1", - "vitaminc-protected 0.5.1", + "vitaminc 0.6.0", + "vitaminc-protected 0.6.0", "zeroize", "zerokms-protocol", ] @@ -4698,9 +4696,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow 1.0.3", ] @@ -5037,13 +5035,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ "vitaminc-aead 0.5.1", - "vitaminc-context", + "vitaminc-context 0.5.1", "vitaminc-encrypt 0.5.1", "vitaminc-protected 0.5.1", "vitaminc-random 0.5.1", "vitaminc-traits 0.5.1", ] +[[package]] +name = "vitaminc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0c6b8dff9bfc94e076fcd5496395958e6c8dddb27605b6504c54123512c7c8e" +dependencies = [ + "vitaminc-aead 0.6.0", + "vitaminc-context 0.6.0", + "vitaminc-encrypt 0.6.0", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", + "vitaminc-traits 0.6.0", +] + [[package]] name = "vitaminc-aead" version = "0.2.0-pre" @@ -5065,13 +5077,30 @@ checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", - "vitaminc-aead-derive", - "vitaminc-context", + "vitaminc-aead-derive 0.5.1", + "vitaminc-context 0.5.1", "vitaminc-protected 0.5.1", "vitaminc-random 0.5.1", "zeroize", ] +[[package]] +name = "vitaminc-aead" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcea687f745050d80df32c93fbe7f8d18ed2cc5fb5e029814477dfe31449ec2e" +dependencies = [ + "bytes", + "futures-util", + "serde", + "thiserror 2.0.20", + "vitaminc-aead-derive 0.6.0", + "vitaminc-context 0.6.0", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", + "zeroize", +] + [[package]] name = "vitaminc-aead-derive" version = "0.5.1" @@ -5084,14 +5113,14 @@ dependencies = [ ] [[package]] -name = "vitaminc-aead-value" -version = "0.5.1" +name = "vitaminc-aead-derive" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d78cdadbc6374c3bd7c19fb4d99abca693b96953b18fef8c608efbe092cf3ea" +checksum = "085afc8c77ffadb4c8a93c9c5fa2d897196a36ab38e17c08fe96f8b9c309e05f" dependencies = [ - "vitaminc-aead 0.5.1", - "vitaminc-protected 0.5.1", - "zeroize", + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] @@ -5104,6 +5133,16 @@ dependencies = [ "vitaminc-protected 0.5.1", ] +[[package]] +name = "vitaminc-context" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4d5679e511871da942ba2b29fcb1df198ee2359218bf7f11cfec4fd3dcfeee0" +dependencies = [ + "mutants", + "vitaminc-protected 0.6.0", +] + [[package]] name = "vitaminc-encrypt" version = "0.2.0-pre" @@ -5132,29 +5171,43 @@ dependencies = [ "zeroize", ] +[[package]] +name = "vitaminc-encrypt" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061d55dc4084fcc2eaa7334dd28300cd8d321038339a41085b7bf2dc6ccf5d4c" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead 0.6.0", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", + "zeroize", +] + [[package]] name = "vitaminc-hmac" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15fe558a5e92d4f2b3f811c5b67d863d9255da5f66d08a71d4f0dd3d0011c563" +checksum = "393354b2348eabce0420626fdc7cf70ac779870f82abb566fb491ea19eeea066" dependencies = [ "hmac 0.13.0", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.5.1", + "vitaminc-protected 0.6.0", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712d31d46d76b38c6b62deb35f516fc5636a35f90f39c0213d0ca9a052b4400b" +checksum = "11de28e524ab1deecedbb023bd328916b6c023e2f52156521ac1df75a689d911" dependencies = [ "mutants", "thiserror 2.0.20", - "vitaminc-context", - "vitaminc-protected 0.5.1", + "vitaminc-context 0.6.0", + "vitaminc-protected 0.6.0", ] [[package]] @@ -5189,6 +5242,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "vitaminc-protected" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efe97c67b61fd749b20d84248416a6cc1dc3f024a4695e3f69eee63aee7cb7c2" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.20", + "vitaminc-protected-derive 0.6.0", + "zeroize", +] + [[package]] name = "vitaminc-protected-derive" version = "0.2.0-pre" @@ -5211,6 +5281,18 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "vitaminc-protected-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0483ecfcb3e874d45c9b2dc04d8e5ed55ff453023a9de98fddc4a24b315d9f6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "vitaminc-random" version = "0.2.0-pre" @@ -5240,6 +5322,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "vitaminc-random" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693ae19994e6a79f9839e510220f532daab6f2ab0b4167fdd0d8379a027c653f" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand 0.10.1", + "thiserror 2.0.20", + "vitaminc-protected 0.6.0", + "vitaminc-random-derives 0.6.0", + "zeroize", +] + [[package]] name = "vitaminc-random-derives" version = "0.2.0-pre" @@ -5262,6 +5359,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "vitaminc-random-derives" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fb9ad6bb972f7e1b0889eba0a3ed534afe86dd8e52f197a2de20ebe25ee9e56" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "vitaminc-traits" version = "0.2.0-pre" @@ -5294,6 +5402,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "vitaminc-traits" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c9f86c930cb4f98eee9288f463a133f01774be039ba05a092f12148b45c430c" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.20", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", + "zeroize", +] + [[package]] name = "vsimd" version = "0.8.0" diff --git a/packages/eql/crates/eql-bindings/CHANGELOG.md b/packages/eql/crates/eql-bindings/CHANGELOG.md index 242167c93..61cbda704 100644 --- a/packages/eql/crates/eql-bindings/CHANGELOG.md +++ b/packages/eql/crates/eql-bindings/CHANGELOG.md @@ -35,6 +35,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **The `stack-encrypt` feature builds on vitaminc ADR 0001.** + `encryption::targets` takes `FfiValue` and `ValueKind` from + `stack_encrypt::dynamic` (the feature turns on stack-encrypt's `dynamic` + feature) in place of `vitaminc-aead-value`, which is gone, and the + `Ciphertext` target takes any vitaminc `Encrypt` plaintext and opens into any + `Decrypt + Send` type, as stack-encrypt's own targets do. + - **Query-operand domain names switched to the `query_` prefix, homed in the `eql_v3` schema.** The SQL domain names carried by the query twins — in `DomainType::sql_domain`, the names `QueryPayload::parse` diff --git a/packages/eql/crates/eql-bindings/Cargo.toml b/packages/eql/crates/eql-bindings/Cargo.toml index 2d1ea219d..87731ac19 100644 --- a/packages/eql/crates/eql-bindings/Cargo.toml +++ b/packages/eql/crates/eql-bindings/Cargo.toml @@ -43,23 +43,19 @@ schemars = "1" # from the PACKAGED crate against the registry stack-encrypt instead: # `cargo publish --dry-run --all-features` in test-eql.yml at PR time, and # `publish_all_features` in ../../release-plz.toml at release time. -stack-encrypt = { path = "../../../stack-encrypt", version = "0.2.0", optional = true, default-features = false } +# `dynamic`: `FfiValue` / `ValueKind`, the runtime plaintext a plan field +# target takes and the kind names a plan's `"type"` key spells +# (`encryption::targets`). +stack-encrypt = { path = "../../../stack-encrypt", version = "0.3.0", optional = true, default-features = false, features = ["dynamic"] } # The vitaminc line stack-encrypt is built on. `PrfValue` is named from this # crate directly (stack-encrypt re-exports only `IntoPrfContext` and # `PrfContext`), and two vitaminc lines in one graph do not interoperate, so # the requirement is stack-encrypt's. -vitaminc-prf = { version = "0.5.0", optional = true } -# `FfiValue` / `ValueKind`: the runtime plaintext a plan field target takes -# and the kind names a plan's `"type"` key spells (`encryption::targets`). -# Named from vitaminc directly, like `PrfValue` above, so the feature needs -# nothing from stack-encrypt's own `dynamic` feature; the same line -# stack-encrypt and the Go guest build against. -vitaminc-aead-value = { version = "0.5.1", optional = true } -# `DecipherVisitor` and `Protected`: the scalar plaintexts in -# `encryption::plaintext` seal and open vitaminc's tagged leaves themselves, -# because vitaminc 0.5 has no native `Encrypt` for the scalar types. -vitaminc-aead = { version = "0.5.1", optional = true } -vitaminc-protected = { version = "0.5.1", optional = true } +vitaminc-prf = { version = "0.6.0", optional = true } +# `Physical`, `Int`, `Float` and `FromScalar`: the scalar plaintexts in +# `encryption::plaintext` describe themselves through vitaminc's `Encrypt` +# and read back only their own width through `FromScalar`. +vitaminc-aead = { version = "0.6.0", optional = true } # The plaintext a target reads out of its `Protected` runtime value is held # in `Zeroizing` until it is sealed, so the copy is wiped like the original. zeroize = { version = "1", optional = true } @@ -72,9 +68,7 @@ default = [] stack-encrypt = [ "dep:stack-encrypt", "dep:vitaminc-prf", - "dep:vitaminc-aead-value", "dep:vitaminc-aead", - "dep:vitaminc-protected", "dep:zeroize", "dep:thiserror", "dep:base64", diff --git a/packages/eql/crates/eql-bindings/README.md b/packages/eql/crates/eql-bindings/README.md index 5a16a25fb..f8cef452a 100644 --- a/packages/eql/crates/eql-bindings/README.md +++ b/packages/eql/crates/eql-bindings/README.md @@ -226,7 +226,7 @@ unknown one with "no such EQL type". ```rust,ignore use eql_bindings::encryption::targets; use stack_encrypt::Label; -use vitaminc_aead_value::FfiValue; +use stack_encrypt::dynamic::FfiValue; let column = Label::new(["users", "email"])?; // the field's context: table/column let stored: Vec = targets::encrypt("TextEq", &keyset, &column, FfiValue::String("alice@example.com".into()))?.await?; diff --git a/packages/eql/crates/eql-bindings/src/encryption.rs b/packages/eql/crates/eql-bindings/src/encryption.rs index af87ceb33..2f4c64673 100644 --- a/packages/eql/crates/eql-bindings/src/encryption.rs +++ b/packages/eql/crates/eql-bindings/src/encryption.rs @@ -199,7 +199,7 @@ impl Visitor for NativeCiphertextVisitor { Ok(CipherText::Single(leaf)) } } -impl + 'static> DecryptInto

for Ciphertext { +impl DecryptInto

for Ciphertext { type Context = AeadContext; fn decryption(self, context: Self::Context) -> Decryption { match self.read(NativeCiphertextVisitor) { diff --git a/packages/eql/crates/eql-bindings/src/encryption/plaintext.rs b/packages/eql/crates/eql-bindings/src/encryption/plaintext.rs index 610a7ad9b..4c990f5fc 100644 --- a/packages/eql/crates/eql-bindings/src/encryption/plaintext.rs +++ b/packages/eql/crates/eql-bindings/src/encryption/plaintext.rs @@ -1,17 +1,20 @@ //! Scalar plaintexts for the v4 number and boolean types. //! //! A Stack Encrypt target encrypts a plaintext through Vitamin C's -//! `Encrypt` and `Decrypt` traits, and vitaminc 0.5 implements -//! `Encrypt` for `String` but not for `i32`, `i64`, `bool`, `f32` or `f64`. -//! These newtypes fill that gap for the storage-only EQL v4 types: [`Int32`] -//! for `Integer`, [`Int64`] for `Bigint`, [`Bool`] for `Boolean`, and -//! [`Float32`] / [`Float64`] for `Real` and `Double`. +//! `Encrypt` and reads it back through `Decrypt`. These newtypes name the +//! plaintext of each storage-only EQL v4 type: [`Int32`] for `Integer`, +//! [`Int64`] for `Bigint`, [`Bool`] for `Boolean`, and [`Float32`] / +//! [`Float64`] for `Real` and `Double`. //! -//! Each seals as vitaminc's tagged leaf, `[tag] ++ payload` from the frozen -//! table in `vitaminc_aead_value::tags`, which is exactly what the value enum -//! (`FfiValue`) seals for the same value. So a value written through these -//! types and one written by the Go SDK as a plain `int32` field open each -//! other. +//! Each describes itself as the primitive it wraps, so it seals the leaf +//! vitaminc seals for that primitive, which is the leaf a data plan's field of +//! the same type seals. So a value written through these types and one +//! written by the Go SDK as a plain `int32` field open each other. +//! +//! Each reads back only its own width. vitaminc's primitive targets convert +//! an integer of any width that fits, and widen an `f32` to an `f64`; these +//! refuse a leaf of another width, so a column's type is the one it was +//! written with. //! //! None of them derives an equality term. `IntegerEq` and `BigintEq` are held //! back until vitaminc 0.6, whose integer equality term uses one PRF domain @@ -29,41 +32,12 @@ //! None of them implements `Debug`, so a plaintext does not reach a log by //! accident. -use stack_encrypt::{Cipher, Decipher, Decrypt, Encrypt, IntoAad, Unspecified}; -use vitaminc_aead::DecipherVisitor; -use vitaminc_aead_value::tagged::{ - TaggedBoolFalse, TaggedBoolTrue, TaggedFloat32, TaggedFloat64, TaggedInt32, TaggedInt64, -}; -use vitaminc_aead_value::tags; -use vitaminc_protected::{Controlled, Protected}; +use stack_encrypt::Encrypt; +use vitaminc_aead::{Float, FromScalar, Int, Logical, Physical, Sink}; use zeroize::Zeroize; -/// Reads one tagged scalar leaf back, refusing any other tag or width: a -/// value sealed as one kind never opens as another. -struct LeafVisitor { - tag: u8, - parse: fn(&[u8]) -> Option, -} - -impl<'c, T: Send> DecipherVisitor<'c> for LeafVisitor { - type Value = T; - - fn visit_bytes_vec(self, data: Protected>) -> Result { - match data.risky_ref().split_first() { - Some((&tag, payload)) if tag == self.tag => (self.parse)(payload).ok_or(Unspecified), - _ => Err(Unspecified), - } - } -} - -/// Bytes a [`LeafVisitor`] reads back: one tag, then exactly `N` payload -/// bytes. Anything else is not this kind. -fn leaf_payload(payload: &[u8]) -> Option<[u8; N]> { - payload.try_into().ok() -} - macro_rules! fixed_scalar { - ($(#[$doc:meta])* $name:ident($inner:ty), $tagged:ty, $tag:expr, $width:literal, |$bytes:ident| $decode:expr, |$a:ident, $b:ident| $eq:expr) => { + ($(#[$doc:meta])* $name:ident($inner:ty), $read:pat => $value:expr, |$a:ident, $b:ident| $eq:expr) => { $(#[$doc])* #[derive(Clone, Copy, Zeroize)] pub struct $name(pub $inner); @@ -92,35 +66,21 @@ macro_rules! fixed_scalar { } } + /// Describes itself as the primitive it wraps. impl Encrypt for $name { - fn encrypt_with_aad<'a, C, A>(self, cipher: C, aad: A) -> Result - where - C: Cipher, - A: IntoAad<'a>, - { - <$tagged>::from(self.0).encrypt_with_aad(cipher, aad) + fn visit(self, sink: S) -> Result { + self.0.visit(sink) } } - impl<'c> Decrypt<'c> for $name { - fn decrypt_with_aad<'a, D, A>(decipher: D, aad: A) -> D::Ok - where - D: Decipher<'c>, - A: IntoAad<'a>, - { - decipher.decrypt_any(Self::visitor(), aad) - } - } - - impl $name { - /// The reader [`Decrypt`] opens this type's leaf with. - fn visitor() -> LeafVisitor { - LeafVisitor { - tag: $tag, - parse: |payload| { - let $bytes = leaf_payload::<$width>(payload)?; - Some($name($decode)) - }, + /// Reads back a leaf of exactly this kind and width, and nothing + /// else: a value sealed as one kind never opens as another. + impl FromScalar for $name { + const EXPECTED: &'static str = stringify!($inner); + fn from_scalar(ty: Option, value: Physical<'_>) -> Option { + match (ty, value) { + (None, $read) => Some($name($value)), + _ => None, } } } @@ -129,25 +89,23 @@ macro_rules! fixed_scalar { fixed_scalar!( /// The plaintext of `Integer`: a 32-bit signed integer. - Int32(i32), TaggedInt32, tags::INT32, 4, |b| i32::from_le_bytes(b), |a, b| a == b + Int32(i32), Physical::Int(Int::I32(v)) => v, |a, b| a == b ); fixed_scalar!( /// The plaintext of `Bigint`: a 64-bit signed integer. - Int64(i64), TaggedInt64, tags::INT64, 8, |b| i64::from_le_bytes(b), |a, b| a == b + Int64(i64), Physical::Int(Int::I64(v)) => v, |a, b| a == b ); fixed_scalar!( /// The plaintext of `Real`: a 32-bit float, sealed by its bit pattern, so /// `-0.0` and a NaN's payload survive a round trip. - Float32(f32), TaggedFloat32, tags::FLOAT32, 4, |b| f32::from_bits(u32::from_le_bytes(b)), - |a, b| a.to_bits() == b.to_bits() + Float32(f32), Physical::Float(Float::F32(v)) => v, |a, b| a.to_bits() == b.to_bits() ); fixed_scalar!( /// The plaintext of `Double`: a 64-bit float, sealed by its bit pattern. - Float64(f64), TaggedFloat64, tags::FLOAT64, 8, |b| f64::from_bits(u64::from_le_bytes(b)), - |a, b| a.to_bits() == b.to_bits() + Float64(f64), Physical::Float(Float::F64(v)) => v, |a, b| a.to_bits() == b.to_bits() ); -/// The plaintext of `Boolean`. A boolean is the tag itself: no payload. +/// The plaintext of `Boolean`. #[derive(Clone, Copy, PartialEq, Eq, Zeroize)] pub struct Bool(pub bool); @@ -163,118 +121,82 @@ impl From for bool { } } +/// Describes itself as a `bool`. impl Encrypt for Bool { - fn encrypt_with_aad<'a, C, A>(self, cipher: C, aad: A) -> Result - where - C: Cipher, - A: IntoAad<'a>, - { - if self.0 { - TaggedBoolTrue::tag_only().encrypt_with_aad(cipher, aad) - } else { - TaggedBoolFalse::tag_only().encrypt_with_aad(cipher, aad) - } + fn visit(self, sink: S) -> Result { + self.0.visit(sink) } } -/// Opens either boolean tag, and nothing else. -struct BoolVisitor; - -impl<'c> DecipherVisitor<'c> for BoolVisitor { - type Value = Bool; - - fn visit_bytes_vec(self, data: Protected>) -> Result { - match data.risky_ref().as_slice() { - [tags::BOOL_TRUE] => Ok(Bool(true)), - [tags::BOOL_FALSE] => Ok(Bool(false)), - _ => Err(Unspecified), +/// Reads back a boolean leaf, and nothing else. +impl FromScalar for Bool { + const EXPECTED: &'static str = "bool"; + fn from_scalar(ty: Option, value: Physical<'_>) -> Option { + match (ty, value) { + (None, Physical::Bool(b)) => Some(Bool(b)), + _ => None, } } } -impl<'c> Decrypt<'c> for Bool { - fn decrypt_with_aad<'a, D, A>(decipher: D, aad: A) -> D::Ok - where - D: Decipher<'c>, - A: IntoAad<'a>, - { - decipher.decrypt_any(BoolVisitor, aad) - } -} - #[cfg(test)] mod tests { - //! The leaf each type reads back, byte for byte. Sealing goes through - //! vitaminc's own `Tagged*` types; what is hand-written here is the - //! reading, so these pin its tag, width and byte order. The round trip - //! against the value enum (`FfiValue`) is in the encryption test crate + //! What each type reads back. Sealing is the primitive's own; what is + //! written here is the reading, so these pin that each type takes only + //! its own kind and width. The round trip against the value enum + //! (`FfiValue`) is in the encryption test crate //! (`tests/encryption/tests/v4_types.rs`). use super::*; - fn leaf(tag: u8, payload: &[u8]) -> Protected> { - let mut bytes = vec![tag]; - bytes.extend_from_slice(payload); - Protected::new(bytes) - } - - fn read<'c, V: DecipherVisitor<'c>>(visitor: V, bytes: Protected>) -> Option { - visitor.visit_bytes_vec(bytes).ok() + fn read(value: Physical<'_>) -> Option { + T::from_scalar(None, value) } #[test] - fn integers_read_their_own_little_endian_leaf() { - let (int32, int64) = (Int32::visitor, Int64::visitor); + fn integers_read_their_own_width() { for v in [0, 1, -1, 42, i32::MIN, i32::MAX] { - assert!(read(int32(), leaf(tags::INT32, &v.to_le_bytes())) == Some(Int32(v))); + assert!(read::(Physical::Int(Int::I32(v))) == Some(Int32(v))); } for v in [0, -1, 42, i64::MIN, i64::MAX] { - assert!(read(int64(), leaf(tags::INT64, &v.to_le_bytes())) == Some(Int64(v))); + assert!(read::(Physical::Int(Int::I64(v))) == Some(Int64(v))); } - // Little-endian, not big: 1 is 01 00 00 00. - assert!(read(int32(), leaf(tags::INT32, &[1, 0, 0, 0])) == Some(Int32(1))); } #[test] - fn a_leaf_of_another_tag_or_width_is_refused() { - let (int32, int64) = (Int32::visitor, Int64::visitor); + fn a_scalar_of_another_kind_or_width_is_refused() { // An Int64 leaf is not an Int32, even when its value would fit. - assert!(read(int32(), leaf(tags::INT64, &1i64.to_le_bytes())).is_none()); - assert!(read(int64(), leaf(tags::INT32, &1i32.to_le_bytes())).is_none()); - // The right tag with the wrong width. - assert!(read(int32(), leaf(tags::INT32, &1i64.to_le_bytes())).is_none()); - assert!(read(int32(), leaf(tags::INT32, &[1, 0, 0])).is_none()); - assert!(read(int64(), leaf(tags::INT64, &1i32.to_le_bytes())).is_none()); - // No tag at all. - assert!(read(int32(), Protected::new(Vec::new())).is_none()); - // A float's leaf is not an integer's. - assert!(read(int32(), leaf(tags::FLOAT32, &1f32.to_le_bytes())).is_none()); + assert!(read::(Physical::Int(Int::I64(1))).is_none()); + assert!(read::(Physical::Int(Int::I32(1))).is_none()); + assert!(read::(Physical::Int(Int::U32(1))).is_none()); + // A float's leaf is not an integer's, nor a boolean's. + assert!(read::(Physical::Float(Float::F32(1.0))).is_none()); + assert!(read::(Physical::Int(Int::I32(1))).is_none()); + // An f32 leaf is not an f64, though it would widen exactly. + assert!(read::(Physical::Float(Float::F32(1.0))).is_none()); + assert!(read::(Physical::Float(Float::F64(1.0))).is_none()); + // A logical type on the leaf is not the bare scalar. + assert!(Int32::from_scalar(Some(Logical::Date), Physical::Int(Int::I32(1))).is_none()); } #[test] fn floats_read_their_bit_pattern() { - let (f32s, f64s) = (Float32::visitor, Float64::visitor); let nan32 = f32::from_bits(0x7fc0_1234); for v in [0.0, -0.0, 1.5, f32::INFINITY, f32::NEG_INFINITY, nan32] { - let opened = read(f32s(), leaf(tags::FLOAT32, &v.to_bits().to_le_bytes())).unwrap(); + let opened = read::(Physical::Float(Float::F32(v))).unwrap(); assert_eq!(opened.0.to_bits(), v.to_bits()); } let nan64 = f64::from_bits(0x7ff8_0000_0000_beef); for v in [0.0, -0.0, 1.5, f64::INFINITY, nan64] { - let opened = read(f64s(), leaf(tags::FLOAT64, &v.to_bits().to_le_bytes())).unwrap(); + let opened = read::(Physical::Float(Float::F64(v))).unwrap(); assert_eq!(opened.0.to_bits(), v.to_bits()); } - assert!(read(f64s(), leaf(tags::FLOAT32, &1f32.to_le_bytes())).is_none()); - assert!(read(f32s(), leaf(tags::FLOAT32, &1f64.to_le_bytes())).is_none()); } #[test] - fn booleans_are_the_tag_alone() { - assert!(read(BoolVisitor, Protected::new(vec![tags::BOOL_TRUE])) == Some(Bool(true))); - assert!(read(BoolVisitor, Protected::new(vec![tags::BOOL_FALSE])) == Some(Bool(false))); - // A payload after the tag, or another tag, is not a boolean. - assert!(read(BoolVisitor, Protected::new(vec![tags::BOOL_TRUE, 0])).is_none()); - assert!(read(BoolVisitor, leaf(tags::INT32, &1i32.to_le_bytes())).is_none()); - assert!(read(BoolVisitor, Protected::new(Vec::new())).is_none()); + fn booleans_read_both_values() { + assert!(read::(Physical::Bool(true)) == Some(Bool(true))); + assert!(read::(Physical::Bool(false)) == Some(Bool(false))); + assert!(read::(Physical::Null).is_none()); } #[test] diff --git a/packages/eql/crates/eql-bindings/src/encryption/targets.rs b/packages/eql/crates/eql-bindings/src/encryption/targets.rs index 764acb045..703cf4a15 100644 --- a/packages/eql/crates/eql-bindings/src/encryption/targets.rs +++ b/packages/eql/crates/eql-bindings/src/encryption/targets.rs @@ -79,12 +79,12 @@ use std::fmt; use serde::{de::DeserializeOwned, Serialize}; +use stack_encrypt::dynamic::{FfiValue, ValueKind}; use stack_encrypt::kms::MaybeSend; use stack_encrypt::target::ExpectedContext; use stack_encrypt::{ DecryptInto, EncryptFrom, Error, KeysetCipher, Label, NonEmpty, Pending, StackCipher, }; -use vitaminc_aead_value::{FfiValue, ValueKind}; use super::plaintext; use crate::v4::targets::{decrypt_named, encrypt_named, query_named, TARGETS}; diff --git a/packages/eql/crates/eql-bindings/src/v4/targets.rs b/packages/eql/crates/eql-bindings/src/v4/targets.rs index 1fb65935f..9b7170749 100644 --- a/packages/eql/crates/eql-bindings/src/v4/targets.rs +++ b/packages/eql/crates/eql-bindings/src/v4/targets.rs @@ -9,8 +9,8 @@ use crate::encryption::targets::{ open_target, refuse, refuse_query, run_target, Opener, Target, TargetError, }; use crate::Identifier; +use stack_encrypt::dynamic::FfiValue; use stack_encrypt::{KeysetCipher, NonEmpty, Pending}; -use vitaminc_aead_value::FfiValue; /// Every EQL type a plan may name as a target, in /// `eql-domains::CATALOG` order: one per stored domain (every flat /// scalar domain plus the SteVec document). Query twins are not diff --git a/packages/eql/crates/eql-codegen/src/targets.rs b/packages/eql/crates/eql-codegen/src/targets.rs index d13ae65dc..4b8f3d7b2 100644 --- a/packages/eql/crates/eql-codegen/src/targets.rs +++ b/packages/eql/crates/eql-codegen/src/targets.rs @@ -277,7 +277,7 @@ fn render_targets_from(rows: &[Row]) -> String { let file = quote! { #![doc = #mod_doc] - use vitaminc_aead_value::FfiValue; + use stack_encrypt::dynamic::FfiValue; #imports use crate::encryption::targets::{#helpers refuse, refuse_query, Opener, Target, TargetError}; diff --git a/packages/eql/tests/encryption/Cargo.toml b/packages/eql/tests/encryption/Cargo.toml index 993b27f7e..d7d5c6576 100644 --- a/packages/eql/tests/encryption/Cargo.toml +++ b/packages/eql/tests/encryption/Cargo.toml @@ -17,10 +17,11 @@ eql-bindings = { path = "../../crates/eql-bindings", features = ["stack-encrypt" # The parity oracle for the generated target table (tests/targets.rs): the # table must name every stored catalog domain, in catalog order. eql-domains = { path = "../../crates/eql-domains" } -stack-encrypt = { path = "../../../stack-encrypt", default-features = false } -# `FfiValue`: the runtime plaintext the target dispatch takes. The same line -# eql-bindings names. -vitaminc-aead-value = "0.5.1" +# `dynamic`: `FfiValue`, the runtime plaintext the target dispatch takes, +# as eql-bindings names it. +stack-encrypt = { path = "../../../stack-encrypt", default-features = false, features = ["dynamic"] } +# `plaintext::Sink`, for a plaintext type with its own `Encrypt` impl. +vitaminc-aead = "0.6.0" stack-kms = { path = "../../../stack-kms", default-features = false, features = ["test-support"] } serde = "1" serde_json = "1" diff --git a/packages/eql/tests/encryption/fixtures/v4_rows.json b/packages/eql/tests/encryption/fixtures/v4_rows.json index 7f233b031..932bc7da6 100644 --- a/packages/eql/tests/encryption/fixtures/v4_rows.json +++ b/packages/eql/tests/encryption/fixtures/v4_rows.json @@ -21,7 +21,7 @@ "t": "profiles", "c": "active" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAA7z71FqWkCCWSkyPiBK7IQIAD0ug/nDu5R6UBEM9vJjtvDMBTuommRH359HgY7qUe5cAGqLPvdkpm6yw99+ZyvZ/D0GwxwF7jiS0n79tWSkg==" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAA7z71FqWkCCWSkyPiBK7IQIAD0ug/nDu5R6UBEM9vJjtvDMBTuommRH359HgY7qUe5cAKqQ9Rw3pcUd7BfnKYrOhzckWm8HdRMHxyVzd9RFw==" }, "balance": { "v": 4, @@ -29,7 +29,7 @@ "t": "profiles", "c": "balance" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAZJDEBcfVqv/o1B2CASJrAIAA9a/qD4wfv0t6fGVxDhKQOlQDIuYj5si13wnlmGENABAF7hMm8/oFuh1IxWGO3alDB86saK1iUeiWeC+NH10iDJzaWG8JP" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAZJDEBcfVqv/o1B2CASJrAIAA9a/qD4wfv0t6fGVxDhKQOlQDIuYj5si13wnlmGENABAL2IJzA54Z5ngSRGOJOvqJHMZMxYFECpqo2ctbI2/lFvH4dB2UG" }, "name": { "v": 4, @@ -37,7 +37,7 @@ "t": "profiles", "c": "name" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAASxNA10KeHfMqSIglat8/GIADzC2+uswJkz9HL6DqUBBNDtGODBX5yNHLKryZ0Lgc24AHLdWoZqUFokOA4X/RHwY8HLX/T8ltWVZz/heIAOGxpuGY=", + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAASxNA10KeHfMqSIglat8/GIADzC2+uswJkz9HL6DqUBBNDtGODBX5yNHLKryZ0Lgc24AI7p9YY7R27PBmPccknBNdQVQ5bN3ZoYucccbZKDKHtZ5Oi", "bf": [ 59, 96, @@ -65,7 +65,7 @@ "t": "profiles", "c": "note" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAACaWtPMkbUZL3WUSHDD8aD7IADB6CfVElii+gglc1gys6JmpNlLoIFn47AvStPnpJb63wEGdkbtSd+MIQURFj5j9R0iQpEA9zXGHSnKKTDGDBmveJjSwGn2r0NA2cM=" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAACaWtPMkbUZL3WUSHDD8aD7IADB6CfVElii+gglc1gys6JmpNlLoIFn47AvStPnpJb63wKnKP6D8GlSml9xks+a/2m67Xf/hmkdJoIjSiHatSvocY0rmNYwOK7y8D/1" }, "ratio": { "v": 4, @@ -73,7 +73,7 @@ "t": "profiles", "c": "ratio" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAuHszuxtkGGxdJ+yeeEgrtIACJ8iRTZnq+2wD3LmxdAqmDDHzDzzH5th5lG8EQq4qujQEsYLgPsjMiNaL8ePzWTx0zmDmQKF7VtItuQqEFLJUguIE=" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAuHszuxtkGGxdJ+yeeEgrtIACJ8iRTZnq+2wD3LmxdAqmDDHzDzzH5th5lG8EQq4qujQLHE5wCg6qAj8/uMYEVG5qF6L5gXLr5QaZstV9YZc45ZOQ=" }, "score": { "v": 4, @@ -81,7 +81,7 @@ "t": "profiles", "c": "score" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAbQulFdQ7itXP4biDOHDhxIAA4jkCscZEzyZymEuOdqFk34IZ2lMPnDiYFZgGjl+Qh0gEDR8u18kphPu/+f4858qnbijVEzqzfHPJGCXD5w1l5bXR99M8f" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAbQulFdQ7itXP4biDOHDhxIAA4jkCscZEzyZymEuOdqFk34IZ2lMPnDiYFZgGjl+Qh0gLxDHYdX6NYqmC8h8famVOyZaf25m+nnYQN9DUOpfNIY1VytlWH" }, "visits": { "v": 4, @@ -89,7 +89,7 @@ "t": "profiles", "c": "visits" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAFDIc6s4iXDM+dzEA4ntTLIACXLoQBRqkNqM7yC8syykzCq7A4M32xA4/pWvh7C8atkgGTGrJ9dKvtm/a2fJWaHbrmXpEgNIWno718QRpijgZ/hYA=" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAFDIc6s4iXDM+dzEA4ntTLIACXLoQBRqkNqM7yC8syykzCq7A4M32xA4/pWvh7C8atkgLCJ46LW8E8GmjsBQyX5huv9Uu+kXR6u0IAiilhVTd0WUE=" } } }, @@ -111,7 +111,7 @@ "t": "profiles", "c": "active" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAABGXg/rWzFqWgcCw0KVmlV7IADK5ojyhDsQsCNphHB+3xjIrvP/87lbuOrEITfr0CumXgHI4YSxjlcPwyOXHLayGQAGvwWuAFlpYrH5X+d9Vg==" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAABGXg/rWzFqWgcCw0KVmlV7IADK5ojyhDsQsCNphHB+3xjIrvP/87lbuOrEITfr0CumXgLUiEl5E+cspiz1C60uZ+fJwoQTdc+Jp+a5wakFmw==" }, "balance": { "v": 4, @@ -119,7 +119,7 @@ "t": "profiles", "c": "balance" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAACHOuVBG9X5O4D9ApUC1DpIADAVYvyCPRFFVrNaWA5vGxA5PF75NkyEagzyfXSd1UIKwHr8BV896CyFbYSClca7yamexnn0aL1nH9V0jpHrTkTj1DFD5MC" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAACHOuVBG9X5O4D9ApUC1DpIADAVYvyCPRFFVrNaWA5vGxA5PF75NkyEagzyfXSd1UIKwLmqZbAOf4jZccVcQQuUwb7UNTUV2ss2ui6ssAwQzw+z8g0PEF4" }, "name": { "v": 4, @@ -127,7 +127,7 @@ "t": "profiles", "c": "name" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAlsDmtaCz7PVYXLYbtkdYiIADMhjWHFWFQffW6FoF5Kd0/vSO5UX+T+vZyisdueEEtugFjeI9b2b8JAmPZVA1YwGnWAQvTk/NzycoZCSO93CEGlPqUfS99", + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAlsDmtaCz7PVYXLYbtkdYiIADMhjWHFWFQffW6FoF5Kd0/vSO5UX+T+vZyisdueEEtugJ8AJTL+FsTQv84umI+UgkWWr5miQkExs7tdXZQZb8WjeymUZN0aw==", "bf": [ 3, 15, @@ -179,7 +179,7 @@ "t": "profiles", "c": "note" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAABtkZKQt8AupdgAkSYhBWuLIABushFe05tB4gQmcP9j+7VPX95cAW65PgvtgFajKobc6gFRQvqrq/jE0/A6z/h1wkJaprHCR2rzks2dQO0T" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAABtkZKQt8AupdgAkSYhBWuLIABushFe05tB4gQmcP9j+7VPX95cAW65PgvtgFajKobc6gK+nIJANyuGOdzfonrK9/ZuZwh0i7+r+umozYODDw==" }, "ratio": { "v": 4, @@ -187,7 +187,7 @@ "t": "profiles", "c": "ratio" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAADJpOTT8kFqPHwznhWnYNgQIAC3GPCNDOMGBnh6gPSa5KTDr+VBQDNZAZr7kqKF16sx0QGRhJu9MKyPnOHlPvMXoeAJaiwDz/4FFzW39/K6QYHakms=" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAADJpOTT8kFqPHwznhWnYNgQIAC3GPCNDOMGBnh6gPSa5KTDr+VBQDNZAZr7kqKF16sx0QLztGogKoty9XImWxjaNUYSF8LDSdG148wg16xStACiBvw=" }, "score": { "v": 4, @@ -195,7 +195,7 @@ "t": "profiles", "c": "score" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAABAfBAgu3xTGBg6oVcxVdCnIADQvBhh5sMdxmdosyrWjFQngkmQ+312sbVAFpgs2UBpcQFVAT9+lg/ey6n6zeWVQydtF2UVswD18AhJnGMs3DGhMVnxU7Ow" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAABAfBAgu3xTGBg6oVcxVdCnIADQvBhh5sMdxmdosyrWjFQngkmQ+312sbVAFpgs2UBpcQLccQgtBqswM85HQZDzsPtZO6xgsqyDMtKBRKqHCzmWAvYCoF9W" }, "visits": { "v": 4, @@ -203,7 +203,7 @@ "t": "profiles", "c": "visits" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAABi/myUaRsBCFbuVTRBJLPiIADSnhrChMGg0gQaL8/2ghirKHXXC41P2aq5DghRI2FtWgHdF0NP0QUsZgASmJF0gwN1v4RdsYU6LW96F5S3NrNBBe0=" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAABi/myUaRsBCFbuVTRBJLPiIADSnhrChMGg0gQaL8/2ghirKHXXC41P2aq5DghRI2FtWgI+v9AtiXTz9LFxGu3KrmHaGwRcO9zEFfxpsWitt/WZob8=" } } } diff --git a/packages/eql/tests/encryption/tests/targets.rs b/packages/eql/tests/encryption/tests/targets.rs index d4089d024..fbf147bd9 100644 --- a/packages/eql/tests/encryption/tests/targets.rs +++ b/packages/eql/tests/encryption/tests/targets.rs @@ -9,8 +9,8 @@ use eql_bindings::encryption::targets::{self as targets, Opener, Target, TargetE use eql_bindings::v4::text::{TextEq, TextEqQuery}; use eql_bindings::{v4, Identifier}; use eql_domains::{Shape, Term, CATALOG}; +use stack_encrypt::dynamic::{FfiValue, ValueKind}; use stack_encrypt::Label; -use vitaminc_aead_value::{FfiValue, ValueKind}; fn email() -> Label { Label::new(["users", "email"]).unwrap() diff --git a/packages/eql/tests/encryption/tests/text_eq.rs b/packages/eql/tests/encryption/tests/text_eq.rs index d1bffd753..2c275c93f 100644 --- a/packages/eql/tests/encryption/tests/text_eq.rs +++ b/packages/eql/tests/encryption/tests/text_eq.rs @@ -7,7 +7,8 @@ use eql_bindings::v4::text::{TextEq, TextEqQuery}; use eql_bindings::v4::SchemaVersion; use eql_bindings::Identifier; use stack_encrypt::target::transcode::{Reader, Transcode}; -use stack_encrypt::{Cipher, Encrypt, IntoAad, NonEmpty}; +use stack_encrypt::{Encrypt, NonEmpty}; +use vitaminc_aead::plaintext::Sink; fn column() -> NonEmpty { Identifier::for_column("users", "email").unwrap() @@ -291,12 +292,13 @@ async fn a_column_batches_generation_and_retrieval_under_the_same_descriptors() #[derive(Clone)] struct WithoutSerde(String); impl Encrypt for WithoutSerde { - fn encrypt_with_aad<'a, C: Cipher, A: IntoAad<'a>>( - self, - cipher: C, - aad: A, - ) -> Result { - self.0.encrypt_with_aad(cipher, aad) + fn visit(self, sink: S) -> Result { + self.0.visit(sink) + } +} +impl Encrypt for &WithoutSerde { + fn visit(self, sink: S) -> Result { + (&self.0).visit(sink) } } #[tokio::test] diff --git a/packages/eql/tests/encryption/tests/v4_types.rs b/packages/eql/tests/encryption/tests/v4_types.rs index de19d5ec8..70ad17053 100644 --- a/packages/eql/tests/encryption/tests/v4_types.rs +++ b/packages/eql/tests/encryption/tests/v4_types.rs @@ -20,9 +20,9 @@ use eql_bindings::v3::terms::Ciphertext; use eql_bindings::v4::{bigint, boolean, double, integer, real, text}; use eql_bindings::Identifier; use serde::{de::DeserializeOwned, Serialize}; +use stack_encrypt::dynamic::{FfiValue, ValueKind}; use stack_encrypt::target::transcode::{Reader, Visitor}; use stack_encrypt::{CipherText, Label, NonEmpty}; -use vitaminc_aead_value::{FfiValue, ValueKind}; fn column() -> NonEmpty { Identifier::for_column("users", "age").unwrap() diff --git a/packages/stack-encrypt-derive/Cargo.toml b/packages/stack-encrypt-derive/Cargo.toml index 223cd01bc..0e90b04d0 100644 --- a/packages/stack-encrypt-derive/Cargo.toml +++ b/packages/stack-encrypt-derive/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-encrypt-derive" description = "Derive macros for stack-encrypt's target-directed encryption" -version = "0.2.0" +version = "0.3.0" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index 99018bf47..7861385d4 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -108,8 +108,8 @@ //! use stack_encrypt::target::transcode::{Transcode, Visitor}; //! use stack_encrypt::target::{self, CallerContext}; //! use stack_encrypt::{ -//! CipherText, Decryptable, Encrypt, EncryptFrom, Encryption, Error, SealedValue, StackCipher, -//! nonempty, +//! CipherText, Decryptable, EncryptFrom, Encryption, Error, Encrypt, SealedValue, +//! StackCipher, nonempty, //! }; //! use stack_encrypt::kms::FakeDataKeySource; //! diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index df56f71ed..6fab532c5 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -142,6 +142,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 same way. Storing the empty filter tells a reader of the database that the value is empty, separator-only or shorter than the n-gram length (ADR-0009). +- **stack-encrypt requires the vitaminc 0.6 crates.** They are the first + release of vitaminc ADR 0001, which the entries below build on. A crate + that names a vitaminc type stack-encrypt takes or returns (`Encrypt`, + `Decrypt`, `Protected`) moves to 0.6 with it. +- **Values describe themselves with vitaminc's new `Encrypt` and read back + with `Decrypt` (vitaminc ADR 0001).** `KeysetCipher::encrypt`, + `StackCipher::decrypt` and every target take the new `Encrypt`, a source + that describes its value to a sink, where they took the old + visitor-driven `Encrypt`, and `Decrypt` where they took + `Decrypt<'static>`; derive both with `vitaminc_aead::{Encrypt, Decrypt}` + (`stack_encrypt::{Encrypt, Decrypt}` re-exports them). The name is the + same and the trait is not: a hand-written impl of the old one does not + compile against the new one. `Decipher`, `Element` and `Cipher` are no + longer re-exported, and `StackDecipher` and `BoxedPassthrough` are gone. + A pending tree is planned rather than driven: + `PendingStackCipherText::plan(value, aad)`, then `seal` or + `into_pending` as before. The key fetch is unchanged: one batched + `generate_keys` per assembly to seal, one `retrieve_keys` per keyset to + open. +- **Every leaf records its type, so a Rust field and a data-plan field + share leaves.** A Rust `u32` and a data plan's `uint32` field seal the + same leaf under the same context, and each opens the other's; a reader of + another kind (text for an integer, an integer for bytes) is refused rather + than handed changed plaintext, as a bare `String` reader used to read a + binding's leaf as `"\nalice"` (#1118). An integer reader accepts an integer + leaf of any width whose value fits, and an `f64` reader an `f32` leaf, + which change no value. + Leaves are vitaminc's wire version 2, so a ciphertext sealed by an + earlier build does not open. +- **An absent value is a sealed null leaf**, a `Single` like any other + value: the absent marker (`CipherText::None`) and + `transcode::Visitor::absent` are gone. +- **A passthrough travels as its frame.** `StackCipherText` is vitaminc's + `Node`: a passthrough subtree is a `PassthroughFrame`, the + transport frame of its value, in place of a boxed value, and + `transcode::Visitor::passthrough` takes one. + A dynamic passthrough value the frame cannot carry is refused when the + source is read, before any key is requested, as `Error::Source` naming + the field: a repeated key as `Reason::RepeatedKey`, and a value nested + deeper, or holding text, bytes or a key longer, than a frame carries as + the new `Reason::Unframeable` (`"unframeable"`). +- **`dynamic::FfiValue` and `dynamic::ValueKind` are this crate's own**, + with the same variants and kind names, since vitaminc no longer has a + value tree. A value crosses a binding's boundary as a transport frame + (`FfiValue::to_frame`, `FfiValue::from_frame`), and is read through its + `Decrypt` sink: an 8- or 16-bit integer in a frame reads as the 32-bit + variant of its sign, and a scalar no variant holds is refused. - **`StackCipher::query_index` is gone**, with `QueryIndexBuilder` and `QueryIndex`. Every query names an operator now that the orderings have diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md index c9a9b678d..ee3130de6 100644 --- a/packages/stack-encrypt/CONTEXT.md +++ b/packages/stack-encrypt/CONTEXT.md @@ -8,8 +8,9 @@ derivation of searchable index terms from the same values. Covers ## Language **Cipher-directed**: -Encryption driven by the value's shape: the value's `Encrypt` implementation -walks the cipher and the caller decides the context, which may be absent. +Encryption driven by the value's shape: the value describes itself once +(vitaminc's `Encrypt`), vitaminc walks it into sealed leaves, and the +caller decides the context, which may be absent. Nothing declares an index and no output type is involved. One of the two preferred entry points for a native Rust caller. A language SDK may leave it out: the Go SDK seals a whole value through a plan, so that every sealed diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index d62c87f47..0351ca5d7 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-encrypt" description = "Encrypt Rust values under per-value ZeroKMS data keys via the vitaminc cipher traits" -version = "0.2.0" +version = "0.3.0" edition.workspace = true authors.workspace = true repository.workspace = true @@ -22,16 +22,14 @@ stack-kms = { path = "../stack-kms", version = "0.1.0", default-features = false # return type names the auto-detected auth strategy. Only needed with `http`. 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" } +stack-encrypt-derive = { path = "../stack-encrypt-derive", version = "0.3.0" } -# The workspace vitaminc (0.5.1): one canonical context encoding shared by +# The workspace vitaminc (0.6.0): 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 # version or the aead crate is duplicated. vitaminc-aead = { workspace = true } -# `dynamic`: the runtime value model every language binding funnels through. -vitaminc-aead-value = { workspace = true, optional = true } vitaminc-encrypt = { workspace = true } vitaminc-hmac = { workspace = true } vitaminc-prf = { workspace = true } @@ -60,8 +58,8 @@ default = ["http"] http = ["dep:stack-auth", "stack-auth/http", "stack-kms/http"] # `stack_encrypt::dynamic`: encrypt and decrypt values whose types are known # only at runtime, as an FFI binding's are. Off by default — it is only of -# use to a binding author, and it pulls `vitaminc-aead-value` in. -dynamic = ["dep:vitaminc-aead-value"] +# use to a binding author. Its transport frames are vitaminc-aead's own. +dynamic = [] # `stack_encrypt::kms::FakeDataKeySource`: the in-memory key source for tests # with no ZeroKMS, so a caller's dev-dependency on stack-encrypt is enough and # no direct stack-kms dependency has to agree with this crate's. diff --git a/packages/stack-encrypt/examples/mixed_user.rs b/packages/stack-encrypt/examples/mixed_user.rs index bb64bd84b..48e3e43ba 100644 --- a/packages/stack-encrypt/examples/mixed_user.rs +++ b/packages/stack-encrypt/examples/mixed_user.rs @@ -9,9 +9,8 @@ //! derivation chain. //! //! Element *positions* are not. Every element of a sequence is sealed under -//! the same derived AAD — deliberately, since that is what lets a single row -//! of a batch decrypt on its own as `Element` — so reordering the elements -//! of a stored sequence still verifies. Order and length are the caller's +//! the same derived AAD, so reordering the elements of a stored sequence +//! still verifies. Order and length are the caller's //! obligation; if they matter, bind them into the AAD yourself or store the //! index alongside the row. //! @@ -19,7 +18,8 @@ //! use them for non-sensitive routing/display data only. //! //! This is the *cipher-directed* layer — vitaminc's `Encrypt` / `Decrypt` -//! driven by hand — one level below `#[derive(EncryptFrom)]`, which the +//! derives, sealed through the keyset directly — one level below +//! `#[derive(EncryptFrom)]`, which the //! `encrypted_record` example uses. Reach for this layer when a value needs //! what the derive does not express: fields stored in the clear beside //! sealed ones, or a single ciphertext whose internal shape (this @@ -37,84 +37,23 @@ //! `CS_WORKSPACE_CRN` and `CS_CLIENT_ID` / `CS_CLIENT_KEY` instead (see the //! `zerokms_auth` example for the lookup order). -use stack_encrypt::{ - Cipher, CipherText, Decipher, Decrypt, Encrypt, IntoAad, Label, StackCipher, StackCipherText, - Unspecified, -}; -use vitaminc_aead::{DecipherVisitor, MapAccess, MapCipher, Passthrough}; +use stack_encrypt::{CipherText, Label, StackCipher, StackCipherText}; +use vitaminc_aead::{Decrypt, Encrypt}; // --- The record type --------------------------------------------------------- -#[derive(Debug, PartialEq)] +// The derives walk the struct as a map keyed by field name. Keys travel in +// the clear; each *encrypted* value is sealed against an AAD derived from the +// map's AAD + its key, so entries cannot be renamed or swapped. Passthrough +// entries carry no such binding. +#[derive(Debug, PartialEq, Encrypt, Decrypt)] struct User { - id: u32, // passthrough: visible in the stored ciphertext - display_name: String, // passthrough - email: String, // encrypted - age: u32, // encrypted -} - -impl Encrypt for User { - fn encrypt_with_aad<'a, C, A>(self, cipher: C, aad: A) -> Result - where - C: Cipher, - A: IntoAad<'a>, - { - // Keys travel in the clear; each *encrypted* value is sealed against - // an AAD derived from the map's AAD + its key, so entries cannot be - // renamed or swapped. Passthrough entries carry no such binding. - cipher - .encrypt_map(aad) - .encrypt_entry("id", Passthrough(self.id))? - .encrypt_entry("display_name", Passthrough(self.display_name))? - .encrypt_entry("email", self.email)? - .encrypt_entry("age", self.age)? - .end() - } -} - -impl<'c> Decrypt<'c> for User { - fn decrypt_with_aad<'a, D, A>(decipher: D, aad: A) -> D::Ok - where - D: Decipher<'c>, - A: IntoAad<'a>, - { - struct UserVisitor; - impl<'c> DecipherVisitor<'c> for UserVisitor { - type Value = User; - - fn visit_map>(self, mut map: M) -> Result { - // Entries arrive in encryption order; each is pulled with its - // expected type and its key is checked. - fn entry<'c, M: MapAccess<'c>, T: Decrypt<'c> + 'c>( - map: &mut M, - key: &str, - ) -> Result { - let (k, value) = map - .next_entry::() - .map_err(|_| Unspecified)? - .ok_or(Unspecified)?; - if k == key { - Ok(value) - } else { - Err(Unspecified) - } - } - - let Passthrough(id) = entry::<_, Passthrough>(&mut map, "id")?; - let Passthrough(display_name) = - entry::<_, Passthrough>(&mut map, "display_name")?; - let email: String = entry(&mut map, "email")?; - let age: u32 = entry(&mut map, "age")?; - Ok(User { - id, - display_name, - email, - age, - }) - } - } - decipher.decrypt_map(UserVisitor, aad) - } + #[aead(passthrough)] + id: u32, // visible in the stored ciphertext + #[aead(passthrough)] + display_name: String, + email: String, // encrypted + age: u32, // encrypted } // --- Inspect what a server would see ----------------------------------------- @@ -131,11 +70,13 @@ fn describe(ciphertext: &StackCipherText, indent: usize) { CipherText::Map(entries) => { for (key, value) in entries { match value { - CipherText::Passthrough(boxed) => { - // Passthrough values are readable without any key. - if let Some(v) = boxed.downcast_ref::() { + CipherText::Passthrough(frame) => { + // Passthrough values are readable without any key: + // the frame of the value, in the clear. + let Ok(frame) = frame.parse() else { continue }; + if let Ok(v) = frame.visit(u32::sink()) { println!("{pad}{key}: {v} (passthrough, in the clear)"); - } else if let Some(v) = boxed.downcast_ref::() { + } else if let Ok(v) = frame.visit(String::sink()) { println!("{pad}{key}: {v:?} (passthrough, in the clear)"); } } diff --git a/packages/stack-encrypt/fuzz/Cargo.lock b/packages/stack-encrypt/fuzz/Cargo.lock index 086027d53..bc151b4f7 100644 --- a/packages/stack-encrypt/fuzz/Cargo.lock +++ b/packages/stack-encrypt/fuzz/Cargo.lock @@ -500,7 +500,7 @@ dependencies = [ "url", "utoipa", "uuid", - "vitaminc", + "vitaminc 0.5.1", ] [[package]] @@ -1520,6 +1520,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -2012,8 +2021,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc", - "vitaminc-protected", + "vitaminc 0.6.0", + "vitaminc-protected 0.6.0", "web-time", "zeroize", "zerokms-protocol", @@ -2021,7 +2030,7 @@ dependencies = [ [[package]] name = "stack-encrypt" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64ct", "cllw-ore", @@ -2032,18 +2041,17 @@ dependencies = [ "stack-kms", "thiserror 1.0.69", "uuid", - "vitaminc-aead", - "vitaminc-aead-value", - "vitaminc-encrypt", + "vitaminc-aead 0.6.0", + "vitaminc-encrypt 0.6.0", "vitaminc-hmac", "vitaminc-prf", - "vitaminc-protected", + "vitaminc-protected 0.6.0", "zeroize", ] [[package]] name = "stack-encrypt-derive" -version = "0.2.0" +version = "0.3.0" dependencies = [ "proc-macro2", "quote", @@ -2058,7 +2066,7 @@ dependencies = [ "libfuzzer-sys", "stack-encrypt", "uuid", - "vitaminc-protected", + "vitaminc-protected 0.6.0", ] [[package]] @@ -2083,8 +2091,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc", - "vitaminc-protected", + "vitaminc 0.6.0", + "vitaminc-protected 0.6.0", "zeroize", "zerokms-protocol", ] @@ -2283,6 +2291,36 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2477,12 +2515,26 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ - "vitaminc-aead", - "vitaminc-context", - "vitaminc-encrypt", - "vitaminc-protected", - "vitaminc-random", - "vitaminc-traits", + "vitaminc-aead 0.5.1", + "vitaminc-context 0.5.1", + "vitaminc-encrypt 0.5.1", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "vitaminc-traits 0.5.1", +] + +[[package]] +name = "vitaminc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0c6b8dff9bfc94e076fcd5496395958e6c8dddb27605b6504c54123512c7c8e" +dependencies = [ + "vitaminc-aead 0.6.0", + "vitaminc-context 0.6.0", + "vitaminc-encrypt 0.6.0", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", + "vitaminc-traits 0.6.0", ] [[package]] @@ -2493,10 +2545,27 @@ checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", - "vitaminc-aead-derive", - "vitaminc-context", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-aead-derive 0.5.1", + "vitaminc-context 0.5.1", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-aead" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcea687f745050d80df32c93fbe7f8d18ed2cc5fb5e029814477dfe31449ec2e" +dependencies = [ + "bytes", + "futures-util", + "serde", + "thiserror 2.0.18", + "vitaminc-aead-derive 0.6.0", + "vitaminc-context 0.6.0", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", "zeroize", ] @@ -2512,14 +2581,14 @@ dependencies = [ ] [[package]] -name = "vitaminc-aead-value" -version = "0.5.1" +name = "vitaminc-aead-derive" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d78cdadbc6374c3bd7c19fb4d99abca693b96953b18fef8c608efbe092cf3ea" +checksum = "085afc8c77ffadb4c8a93c9c5fa2d897196a36ab38e17c08fe96f8b9c309e05f" dependencies = [ - "vitaminc-aead", - "vitaminc-protected", - "zeroize", + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] @@ -2529,7 +2598,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", - "vitaminc-protected", + "vitaminc-protected 0.5.1", +] + +[[package]] +name = "vitaminc-context" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4d5679e511871da942ba2b29fcb1df198ee2359218bf7f11cfec4fd3dcfeee0" +dependencies = [ + "mutants", + "vitaminc-protected 0.6.0", ] [[package]] @@ -2540,35 +2619,49 @@ checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-aead 0.5.1", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061d55dc4084fcc2eaa7334dd28300cd8d321038339a41085b7bf2dc6ccf5d4c" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead 0.6.0", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15fe558a5e92d4f2b3f811c5b67d863d9255da5f66d08a71d4f0dd3d0011c563" +checksum = "393354b2348eabce0420626fdc7cf70ac779870f82abb566fb491ea19eeea066" dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected", + "vitaminc-protected 0.6.0", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712d31d46d76b38c6b62deb35f516fc5636a35f90f39c0213d0ca9a052b4400b" +checksum = "11de28e524ab1deecedbb023bd328916b6c023e2f52156521ac1df75a689d911" dependencies = [ "mutants", "thiserror 2.0.18", - "vitaminc-context", - "vitaminc-protected", + "vitaminc-context 0.6.0", + "vitaminc-protected 0.6.0", ] [[package]] @@ -2584,7 +2677,24 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive", + "vitaminc-protected-derive 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-protected" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efe97c67b61fd749b20d84248416a6cc1dc3f024a4695e3f69eee63aee7cb7c2" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.18", + "vitaminc-protected-derive 0.6.0", "zeroize", ] @@ -2599,6 +2709,18 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "vitaminc-protected-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0483ecfcb3e874d45c9b2dc04d8e5ed55ff453023a9de98fddc4a24b315d9f6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "vitaminc-random" version = "0.5.1" @@ -2609,8 +2731,23 @@ dependencies = [ "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected", - "vitaminc-random-derives", + "vitaminc-protected 0.5.1", + "vitaminc-random-derives 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-random" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693ae19994e6a79f9839e510220f532daab6f2ab0b4167fdd0d8379a027c653f" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand 0.10.1", + "thiserror 2.0.18", + "vitaminc-protected 0.6.0", + "vitaminc-random-derives 0.6.0", "zeroize", ] @@ -2625,6 +2762,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "vitaminc-random-derives" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fb9ad6bb972f7e1b0889eba0a3ed534afe86dd8e52f197a2de20ebe25ee9e56" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "vitaminc-traits" version = "0.5.1" @@ -2636,8 +2784,24 @@ dependencies = [ "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-traits" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c9f86c930cb4f98eee9288f463a133f01774be039ba05a092f12148b45c430c" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.18", + "vitaminc-protected 0.6.0", + "vitaminc-random 0.6.0", "zeroize", ] @@ -2991,6 +3155,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/packages/stack-encrypt/fuzz/Cargo.toml b/packages/stack-encrypt/fuzz/Cargo.toml index 5a3bca512..63378e06c 100644 --- a/packages/stack-encrypt/fuzz/Cargo.toml +++ b/packages/stack-encrypt/fuzz/Cargo.toml @@ -17,7 +17,7 @@ cargo-fuzz = true [dependencies] # `Protected`, for the check_record model's passthrough-of-bytes node: the # same line stack-encrypt builds on. -vitaminc-protected = "0.5.1" +vitaminc-protected = "0.6.0" libfuzzer-sys = "0.4" # Structure-aware inputs for `check_record` and `plan_build`: the mirror # types derive `Arbitrary`, so the fuzzer mutates trees and plans, not bytes. diff --git a/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs b/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs index 7b486afc5..72e53aeae 100644 --- a/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs +++ b/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs @@ -35,9 +35,11 @@ use stack_encrypt::dynamic::record::{check_record, plan_with, Plan}; use stack_encrypt::dynamic::{ FfiValue, Output, TargetDescriptor, TargetError, TargetResolver, ValueKind, }; -use stack_encrypt::{KeysetCipher, Label, Pending, SealedValue, StackCipher, StackCipherText}; +use stack_encrypt::{ + KeysetCipher, Label, PassthroughFrame, Pending, SealedValue, StackCipher, StackCipherText, +}; use uuid::Uuid; -use vitaminc_protected::Protected; +use vitaminc_protected::{Controlled, Protected}; /// The resolver plans parse under: one producible type, `TextEq` over /// strings, and nothing that runs — `check_record` never reaches a cipher. @@ -264,7 +266,6 @@ impl PlanSpec { #[derive(Arbitrary, Debug)] enum Tree { Single, - None, EmptySequence, EmptyMap, Passthrough, @@ -284,17 +285,20 @@ fn leaf() -> SealedValue { .clone() } +/// A value as a passthrough node: its frame, in the clear. +fn passthrough(value: FfiValue) -> StackCipherText { + let frame = value.to_frame().expect("a scalar frames"); + StackCipherText::Passthrough(PassthroughFrame(frame.risky_unwrap())) +} + impl Tree { fn into_ciphertext(self) -> StackCipherText { match self { Tree::Single => StackCipherText::Single(leaf()), - Tree::None => StackCipherText::None(leaf()), Tree::EmptySequence => StackCipherText::EmptySequence(leaf()), Tree::EmptyMap => StackCipherText::EmptyMap(leaf()), - Tree::Passthrough => StackCipherText::Passthrough(Box::new(FfiValue::Null)), - Tree::PassthroughBytes => StackCipherText::Passthrough(Box::new(FfiValue::Bytes( - Protected::new(b"{}".to_vec()), - ))), + Tree::Passthrough => passthrough(FfiValue::Null), + Tree::PassthroughBytes => passthrough(FfiValue::Bytes(Protected::new(b"{}".to_vec()))), Tree::Sequence(items) => { StackCipherText::Sequence(items.into_iter().map(Tree::into_ciphertext).collect()) } @@ -320,7 +324,7 @@ impl Tree { .all(|(at, (name, _))| !entries[..at].iter().any(|(prior, _)| prior == name)); unique && entries.iter().all(|(_, node)| node.is_clean()) } - Tree::Single | Tree::None | Tree::EmptySequence | Tree::EmptyMap => true, + Tree::Single | Tree::EmptySequence | Tree::EmptyMap => true, } } } diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 46103500c..8d48b95d5 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -2,11 +2,13 @@ //! module documents the internals. //! //! `StackCipher` is scoped to one ZeroKMS client; a [`KeysetCipher`] — the -//! cipher bound to one of that client's keysets — is a vitaminc [`Cipher`] -//! whose per-leaf keys are ZeroKMS data keys, minted under that keyset, -//! rather than one fixed key. Structurally it mirrors -//! `vitaminc_encrypt::Aes256Cipher`: encrypting an [`Encrypt`] value produces -//! a recursive ciphertext tree ([`StackCipherText`], the analog of +//! cipher bound to one of that client's keysets — seals values whose +//! per-leaf keys are ZeroKMS data keys, minted under that keyset, rather +//! than one fixed key. The value side is vitaminc's: a value describes +//! itself once ([`Encrypt`]), vitaminc plans it into a tree of pending +//! leaves, each with its tagged plaintext and the AAD its place derives, +//! and a bytes-only [`Cipher`] seals and opens one leaf at a time. The +//! result is a ciphertext tree ([`StackCipherText`], the analog of //! `AesCipherText`) whose leaves ([`SealedValue`]) each carry the ZeroKMS //! metadata for their own data key — the keyset it was minted under //! included, which is why decrypting needs no keyset named and lives on the @@ -14,41 +16,48 @@ //! //! ## Batching the key fetch //! -//! Data-key generation/retrieval is an async ZeroKMS round-trip, so the key -//! fetch cannot happen inside the synchronous [`Cipher`]/[`Decipher`] trait -//! methods. It is front-loaded on both sides; the AES work stays inside the -//! trait drive: +//! Data-key generation/retrieval is an async ZeroKMS round-trip, and each +//! leaf needs its own key, so the keys are fetched for the whole tree +//! before any leaf is sealed or opened. The AES work stays in vitaminc's +//! synchronous walk: //! -//! * **Encrypt** — driving the [`Cipher`] trait over a `&KeysetCipher` builds -//! a *pending* tree ([`PendingStackCipherText`]) that holds plaintext plus -//! each leaf's fully derived AAD, but does no I/O. A single +//! * **Encrypt** — [`PendingStackCipherText::plan`] walks the value into +//! vitaminc's pending tree and does no I/O. A single //! [`PendingStackCipherText::seal`] (or the [`KeysetCipher::encrypt`] //! convenience) then batches **one** `generate_keys` call for the whole -//! tree, under the handle's keyset, and seals every leaf. +//! tree, under the handle's keyset, and seals every leaf through +//! `MintedKeys`, the sealing half of this crate's [`Cipher`], one key per +//! leaf in walk order. //! * **Decrypt** — [`StackCipher::decrypt`] batches **one** `retrieve_keys` -//! call per keyset the leaves were sealed under and zips each key onto its -//! leaf, building a [`StackDecipher`] it does not hand out. The -//! value's [`Decrypt`] impl then drives that decipher exactly as it would -//! `AesDecipher`: each leaf is opened under the AAD the drive supplies, so -//! the visitor pattern (nested `Vec`/`HashMap`/`Option`/`Protected` values, -//! and AAD-deriving wrappers such as `vitaminc_aead::Element`) works -//! identically to `Aes256Cipher`. +//! call per keyset the leaves were sealed under and zips each key onto +//! its leaf. vitaminc's [`decrypt`](vitaminc_aead::decrypt) then opens +//! every leaf through `BoundKeys`, the opening half, and walks the opened +//! tree into the value's [`Decrypt`] sink. +//! +//! Neither half is vitaminc's `AsyncCipher`. Its `seal_many` batches the +//! leaves of one document, but a ZeroKMS request names the descriptor of +//! the context the whole tree is sealed under, which a leaf's derived AAD +//! does not carry, and the target layer merges the key requests of many +//! documents (the fields of a record, the rows of a column) into one call +//! (RFC 0002). So the fetch is the target layer's +//! [`Pending`](crate::target::Pending), and the cipher vitaminc drives is +//! the synchronous one. //! //! ## AAD derivation //! -//! The caller's AAD is refined per node with the same domain-separated -//! derivations `Aes256Cipher` uses, so the container *shape* is authenticated: +//! vitaminc derives each leaf's AAD from the caller's, on both sides, with +//! the same domain-separated derivations every vitaminc cipher gets, so the +//! container *shape* is authenticated: //! //! * sequence elements are sealed under [`Context::for_sequence_element`]; //! * map values under [`Context::for_map_entry`] of their cleartext key (so //! swapping or renaming keys fails decryption); -//! * authenticated-absent markers under [`Context::for_none`], and empty -//! sequences/maps under [`Context::for_empty_sequence`]/[`Context::for_empty_map`] — -//! each sealing an *empty* plaintext, verified as empty on open. +//! * empty sequences/maps under [`Context::for_empty_sequence`] / +//! [`Context::for_empty_map`], each sealing an *empty* plaintext, +//! verified as empty on open. An absent value (`None`) is a null leaf like +//! any other scalar. //! -//! The decrypt side performs the same derivations inside [`StackDecipher`]'s -//! `decrypt_seq`/`decrypt_map`/`decrypt_option` as the caller's `Decrypt` impl -//! drives it, so there is a single source of truth for the per-node AAD. +//! This crate binds its own envelope on top of that, per leaf (below). //! //! ## Leaf crypto and wire format //! @@ -82,9 +91,7 @@ //! concatenation). A compatibility module can be added later to read existing //! records as customers of `cipherstash-client` migrate. -use std::any::Any; -use std::borrow::Cow; -use std::collections::HashSet; +use std::cell::RefCell; use std::num::NonZeroUsize; use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; @@ -96,27 +103,26 @@ use stack_kms::{EnvKeyProvider, StackKms, StackKmsBuilder}; #[cfg(all(feature = "http", not(target_arch = "wasm32")))] use stack_kms::{FallbackKeyProvider, KeyProvider, KeyProviderError, ProfileStore}; use uuid::Uuid; +use vitaminc_aead::seal::seal_all; use vitaminc_aead::{ - Cipher, CipherText, Context, Decipher, DecipherVisitor, Decrypt, Encrypt, IntoAad, IntoContext, - LocalCipherText, MapAccess, MapCipher, SeqAccess, SeqCipher, Unspecified, + Cipher, CipherText, Context, Decrypt, Encrypt, IntoAad, IntoContext, LocalCipherText, Node, + Pending, Unspecified, }; -use vitaminc_encrypt::{Aes256Cipher, AesCipherText, Key as AesKey}; -use vitaminc_protected::{Controlled, Protected}; +use vitaminc_encrypt::{Aes256Cipher, Key as AesKey}; +use vitaminc_protected::Protected; use crate::keyset::{KeysetCache, KeysetCipher, KeysetState, Lookup, DEFAULT_NAME_TTL}; use crate::Descriptor; -/// The passthrough payload type: type-erased, as for Rust-native vitaminc -/// ciphers. Callers box on the way in and downcast on the way out. -pub type BoxedPassthrough = Box; - /// The recursive ciphertext container produced by [`StackCipher`]: vitaminc's -/// generic [`CipherText`] tree over [`SealedValue`] leaves. Its shape -/// mirrors the encrypted plaintext: a scalar yields `Single`, a `Vec` yields -/// `Sequence` (or `EmptySequence`), a `HashMap` or struct yields `Map` (or -/// `EmptyMap`). Map keys are stored in the clear but bound into their value's -/// AAD. -pub type StackCipherText = CipherText; +/// ciphertext tree ([`Node`]) over [`SealedValue`] leaves. Its shape +/// mirrors the encrypted plaintext: a scalar, `None` included, yields +/// `Single`, a `Vec` yields `Sequence` (or `EmptySequence`), a `HashMap` or +/// struct yields `Map` (or `EmptyMap`). Map keys are stored in the clear but +/// bound into their value's AAD. A passthrough subtree travels in the clear +/// as a [`PassthroughFrame`](vitaminc_aead::PassthroughFrame): the subtree's +/// frame bytes. +pub type StackCipherText = Node; /// Errors from sealing or opening a [`StackCipherText`]. /// @@ -816,7 +822,7 @@ impl KeysetCipher<'_, K> { A: IntoContext<'a>, { let context = aad.into_context(); - let pending = value.encrypt_with_aad(self, context.clone().into_aad())?; + let pending = PendingStackCipherText::plan(value, context.clone())?; pending.seal(self, context).await } @@ -825,61 +831,49 @@ impl KeysetCipher<'_, K> { /// is retrieved. pub async fn decrypt<'a, T, A>(&self, ciphertext: StackCipherText, aad: A) -> Result where - T: Decrypt<'static> + 'static, + T: Decrypt, A: IntoContext<'a>, { decrypt_through(self, ciphertext, aad).await } } -/// Retrieve every leaf's data key under `aad`'s descriptor and bind them -/// onto the ciphertext, for either scope. The work is the same on both — -/// one descriptor, one pending, one settle — and the scope is the whole +/// `decrypt`, for either scope: retrieve every leaf's data key under +/// `aad`'s descriptor, bind them onto the ciphertext, and open it into `T` +/// under the same `aad`. The work is the same on both scopes — one +/// descriptor, one pending, one settle — and the scope is the whole /// difference: a [`KeysetCipher`] constrains the leaves to its keyset, a /// [`StackCipher`] constrains nothing. /// -/// Deliberately private. The returned [`StackDecipher`] is driven with an -/// AAD supplied per call, so exposing this would let a caller retrieve keys -/// under one context and authenticate the ciphertext under an unrelated -/// one. Every leaf's AAD is a derivation of the descriptor its key was -/// minted under — `for_sequence_element`, `for_map_entry`, `for_leaf` — -/// and that derivation is the library's to compute, never the caller's to -/// supply. [`decrypt_through`] is the only way in, and it passes one `aad` -/// to both halves. -async fn decipher_through<'s, 'a, K: DataKeySource + 's>( - scope: impl crate::target::CipherScope<'s, K>, - ciphertext: StackCipherText, - aad: impl IntoContext<'a>, -) -> Result { - crate::target::decipher_pending(scope, ciphertext, Descriptor::of(aad)) - .settle() - .await -} - -/// `decrypt`, for either scope: [`decipher_through`], then the value's own -/// [`Decrypt`] drive under the same `aad`. +/// One `aad` goes to both halves, and that is the point: every leaf's AAD +/// is a derivation of the descriptor its key was minted under — +/// `for_sequence_element`, `for_map_entry`, `for_leaf` — and that +/// derivation is the library's to compute, never the caller's to supply. async fn decrypt_through<'s, 'a, T, K: DataKeySource + 's>( scope: impl crate::target::CipherScope<'s, K>, ciphertext: StackCipherText, aad: impl IntoContext<'a>, ) -> Result where - T: Decrypt<'static> + 'static, + T: Decrypt, { let context = aad.into_context(); - let decipher = decipher_through(scope, ciphertext, context.clone()).await?; - T::decrypt_with_aad(decipher, context.into_aad()).map_err(Error::from) + let keyed = crate::target::bind_pending(scope, ciphertext, Descriptor::of(context.clone())) + .settle() + .await?; + open_keyed(keyed, context) } impl StackCipher { /// Decrypt a [`StackCipherText`] into `T`, authenticating against `aad`. /// /// One batched `retrieve_keys` call per keyset the leaves were sealed - /// under, every key under the [`Descriptor`] of `aad`, then `T`'s - /// [`Decrypt`] impl drives the resulting [`StackDecipher`] with the - /// *same* `aad` — exactly as `Aes256Cipher::decrypt_with_aad` drives - /// `AesDecipher`. One context in, and every leaf's AAD derived from it; - /// there is no form of this call that takes two. + /// under, every key under the [`Descriptor`] of `aad`, then vitaminc + /// opens every leaf under the AAD it derives from the *same* `aad` and + /// walks the result into `T`'s [`Decrypt`] sink — exactly as + /// `vitaminc_aead::decrypt` does over an `Aes256Cipher`. One context in, + /// and every leaf's AAD derived from it; there is no form of this call + /// that takes two. /// /// Not keyset-scoped: each leaf carries the id of the keyset it was /// sealed under, and this opens leaves from any keyset the client is @@ -900,7 +894,7 @@ impl StackCipher { /// a foreign leaf before any. pub async fn decrypt<'a, T, A>(&self, ciphertext: StackCipherText, aad: A) -> Result where - T: Decrypt<'static> + 'static, + T: Decrypt, A: IntoContext<'a>, { decrypt_through(self, ciphertext, aad).await @@ -1208,21 +1202,20 @@ impl TryFrom for SealedValue { } /// A leaf with its retrieved data key bound alongside. Produced by -/// [`bind_keys`] once the batched `retrieve_keys` call has returned; consumed by -/// [`StackDecipher`], which opens it under whatever AAD the driving -/// [`Decrypt`] impl supplies. +/// [`bind_keys`] once the batched `retrieve_keys` call has returned; opened +/// by [`BoundKeys`] under the AAD vitaminc's walk derives for its place. pub(crate) struct KeyedLeaf { leaf: SealedValue, key: DataKey, } /// [`StackCipherText`] with a [`DataKey`] zipped onto every keyed leaf. -pub(crate) type KeyedCipherText = CipherText; +pub(crate) type KeyedCipherText = Node; /// Zip retrieved keys onto the tree in the same depth-first order the -/// target layer requested them (`retrieve_requests`), so each leaf carries its -/// own key and the subsequent [`Decipher`] drive is free of ordering -/// assumptions. +/// target layer requested them (`retrieve_requests`), so each leaf carries +/// its own key and opening it makes no assumption about the order vitaminc +/// visits leaves in. pub(crate) fn bind_keys( ciphertext: StackCipherText, keys: &mut impl Iterator, @@ -1237,7 +1230,6 @@ pub(crate) fn bind_keys( match ciphertext { CipherText::Single(leaf) => Ok(CipherText::Single(bind(leaf, keys)?)), - CipherText::None(leaf) => Ok(CipherText::None(bind(leaf, keys)?)), CipherText::EmptySequence(leaf) => Ok(CipherText::EmptySequence(bind(leaf, keys)?)), CipherText::EmptyMap(leaf) => Ok(CipherText::EmptyMap(bind(leaf, keys)?)), CipherText::Sequence(items) => { @@ -1259,59 +1251,53 @@ pub(crate) fn bind_keys( } // ============================================================================= -// Pending tree (`Cipher::Ok`) — built synchronously, sealed asynchronously +// Pending tree — planned synchronously, sealed asynchronously // ============================================================================= -/// The intermediate result of driving the [`Cipher`] trait: a tree that holds -/// plaintext (and the AAD each leaf will be sealed against, fully derived) but -/// has done no ZeroKMS I/O. [`seal`](Self::seal) turns it into a +/// A value planned for sealing: vitaminc's tree of pending leaves, each +/// holding its tagged plaintext and the AAD its place in the value derives, +/// with no ZeroKMS I/O done yet. [`seal`](Self::seal) turns it into a /// [`StackCipherText`]. -pub enum PendingStackCipherText { - /// A scalar awaiting a data key, with its bound (derived) AAD. - Single { - plaintext: Protected>, - aad: Context<'static>, - }, - /// A pending sequence with at least one element. - Sequence(Vec), - /// A pending empty-sequence marker; `aad` is already the - /// [`Context::for_empty_sequence`] derivation. - EmptySequence { aad: Context<'static> }, - /// A pending map with at least one entry. - Map(Vec<(String, PendingStackCipherText)>), - /// A pending empty-map marker; `aad` is already the [`Context::for_empty_map`] - /// derivation. - EmptyMap { aad: Context<'static> }, - /// A pending authenticated-absent marker; `aad` is already the - /// [`Context::for_none`] derivation. - None { aad: Context<'static> }, - /// A passthrough value (needs no key). - Passthrough(BoxedPassthrough), -} +/// +/// [`plan`](Self::plan) walks the value once, through vitaminc's +/// `EncryptSink`, which owns every container rule (element and entry AAD, +/// the empty markers, duplicate keys, a container with nothing sealed in +/// it). This crate decides only where each leaf's key comes from. +pub struct PendingStackCipherText(Node); impl PendingStackCipherText { - /// Number of leaves that need a ZeroKMS data key (everything but - /// passthrough — markers are sealed leaves too). + /// Plan `value` for sealing under `aad`. No key is requested. + /// + /// A value vitaminc refuses to encrypt as given (a map with a repeated + /// key, a container whose every child is a passthrough, a nesting deeper + /// than the transport carries) is [`Error::Aead`], as it is when the + /// value is sealed in one step. + pub fn plan<'a, P: Encrypt>(value: P, aad: impl IntoAad<'a>) -> Result { + vitaminc_aead::plan::<_, Unspecified, _>(value, aad) + .map(Self) + .map_err(|_| Error::Aead) + } + + /// Number of leaves that need a ZeroKMS data key: every leaf, the empty + /// markers included; a passthrough needs none. pub(crate) fn key_count(&self) -> usize { - match self { - PendingStackCipherText::Single { .. } - | PendingStackCipherText::None { .. } - | PendingStackCipherText::EmptySequence { .. } - | PendingStackCipherText::EmptyMap { .. } => 1, - PendingStackCipherText::Sequence(items) => items.iter().map(Self::key_count).sum(), - PendingStackCipherText::Map(entries) => { - entries.iter().map(|(_, v)| v.key_count()).sum() + fn count(node: &Node) -> usize { + match node { + CipherText::Single(_) | CipherText::EmptySequence(_) | CipherText::EmptyMap(_) => 1, + CipherText::Sequence(items) => items.iter().map(count).sum(), + CipherText::Map(entries) => entries.iter().map(|(_, v)| count(v)).sum(), + CipherText::Passthrough(_) => 0, } - PendingStackCipherText::Passthrough(_) => 0, } + count(&self.0) } /// Generate one data key per keyed leaf (one batched ZeroKMS call — none /// for a passthrough-only tree), every key under the [`Descriptor`] of /// `aad`, and seal the whole tree. /// - /// `aad` is the context the tree was built under — the value passed to - /// `encrypt_with_aad`, in the same shape (see the + /// `aad` is the context the tree was planned under — the value passed to + /// [`plan`](Self::plan), in the same shape (see the /// [descriptor docs](crate::descriptor)). The tree itself only carries /// the *derived* per-leaf AADs, so the root is named here; nothing can /// check that the two agree, which is why [`KeysetCipher::encrypt`], which @@ -1334,8 +1320,8 @@ impl PendingStackCipherText { /// /// [`seal`](Self::seal) is this plus an immediate settle — one ZeroKMS /// call per tree. `into_pending` exists for callers that hold *several* - /// independently built trees (each from its own [`Encrypt`] drive, e.g. - /// one per record field in a language binding) and want them merged with + /// independently planned trees (one per record field in a language + /// binding, say) and want them merged with /// [`Pending::zip`](crate::target::Pending::zip) / /// [`Pending::all`](crate::target::Pending::all) so the whole assembly /// seals in **one** batched `generate_keys` call. Same sealing path @@ -1360,56 +1346,18 @@ impl PendingStackCipherText { crate::target::seal_pending(cipher, self, Descriptor::of(aad)) } - /// Recursively seal under `keyset_id`, drawing one key per leaf from - /// `keys` in traversal order. + /// Seal under `keyset_id`, drawing one key per leaf from `keys` in + /// vitaminc's walk order. A leaf with no key left is refused. pub(crate) fn seal_with( self, keyset_id: Uuid, - keys: &mut impl Iterator, + mut keys: impl Iterator, ) -> Result { - // Markers seal an *empty* plaintext so the AEAD tag still binds their - // (already domain-separated) AAD, mirroring `Aes256Cipher`. - fn seal_marker( - aad: Context<'static>, - keyset_id: Uuid, - keys: &mut impl Iterator, - ) -> Result { - let key = keys.next().ok_or(Unspecified)?; - seal_leaf(Protected::new(Vec::new()), &aad, keyset_id, key) - } - - match self { - PendingStackCipherText::Single { plaintext, aad } => { - let key = keys.next().ok_or(Unspecified)?; - Ok(CipherText::Single(seal_leaf( - plaintext, &aad, keyset_id, key, - )?)) - } - PendingStackCipherText::None { aad } => { - Ok(CipherText::None(seal_marker(aad, keyset_id, keys)?)) - } - PendingStackCipherText::EmptySequence { aad } => Ok(CipherText::EmptySequence( - seal_marker(aad, keyset_id, keys)?, - )), - PendingStackCipherText::EmptyMap { aad } => { - Ok(CipherText::EmptyMap(seal_marker(aad, keyset_id, keys)?)) - } - PendingStackCipherText::Sequence(items) => { - let mut out = Vec::with_capacity(items.len()); - for item in items { - out.push(item.seal_with(keyset_id, keys)?); - } - Ok(CipherText::Sequence(out)) - } - PendingStackCipherText::Map(entries) => { - let mut out = Vec::with_capacity(entries.len()); - for (k, v) in entries { - out.push((k, v.seal_with(keyset_id, keys)?)); - } - Ok(CipherText::Map(out)) - } - PendingStackCipherText::Passthrough(value) => Ok(CipherText::Passthrough(value)), - } + let minted = MintedKeys { + keyset_id, + keys: RefCell::new(&mut keys), + }; + seal_all(&minted, self.0).map_err(|_| Unspecified) } } @@ -1466,11 +1414,12 @@ fn leaf_aad(aad: &Context<'_>, keyset_id: Uuid, tag: &[u8]) -> Context<'static> ]) } -/// Seal one plaintext leaf under a freshly generated data key. +/// Seal one leaf's tagged plaintext under a freshly generated data key. /// -/// The AAD is the [`leaf_aad`] derivation of the caller's (derived) AAD, the -/// keyset the key was minted under, and the key `tag` — `tag` is always -/// bound, so the leaf is cryptographically tied to its ZeroKMS data key. +/// The AAD is the [`leaf_aad`] derivation of the AAD vitaminc derived for +/// the leaf's place, the keyset the key was minted under, and the key +/// `tag` — `tag` is always bound, so the leaf is cryptographically tied to +/// its ZeroKMS data key. fn seal_leaf( plaintext: Protected>, aad: &Context<'_>, @@ -1485,641 +1434,96 @@ fn seal_leaf( SealedValue::tag_fits_length_field(&key.tag).map_err(|_| Unspecified)?; let iv = key.key.iv; let cipher = leaf_cipher(&key.key)?; - match (&cipher).encrypt_bytes_vec(plaintext, leaf_aad(aad, keyset_id, &key.tag))? { - AesCipherText::Single(ciphertext) => Ok(SealedValue { - keyset_id, - iv, - tag: key.tag, - ciphertext, - }), - _ => Err(Unspecified), - } + let ciphertext = cipher.seal(plaintext, &leaf_aad(aad, keyset_id, &key.tag))?; + Ok(SealedValue { + keyset_id, + iv, + tag: key.tag, + ciphertext, + }) } -/// Open one keyed leaf under `aad`, returning the plaintext bytes. +/// Open one keyed leaf under `aad`, returning its tagged plaintext. fn open_leaf(keyed: KeyedLeaf, aad: &Context<'_>) -> Result>, Unspecified> { - /// Keeps the recovered bytes inside `Protected` across the visitor - /// boundary (the blanket `Decrypt for Vec` would unwrap them). - struct ProtectedBytes; - impl<'c> DecipherVisitor<'c> for ProtectedBytes { - type Value = Protected>; - fn visit_bytes_vec(self, data: Protected>) -> Result { - Ok(data) - } - } - let KeyedLeaf { leaf, key } = keyed; let cipher = leaf_cipher(&key)?; - cipher - .decipher(AesCipherText::Single(leaf.ciphertext)) - .decrypt_bytes(ProtectedBytes, leaf_aad(aad, leaf.keyset_id, &leaf.tag)) -} - -/// Open one marker leaf (absent / empty-sequence / empty-map) and require the -/// sealed plaintext to be empty. Without the emptiness check, a `Single` leaf -/// could be re-tagged as a marker of the same AAD derivation. -fn verify_empty_marker(keyed: KeyedLeaf, aad: &Context<'_>) -> Result<(), Unspecified> { - let plaintext = open_leaf(keyed, aad)?; - if plaintext.risky_ref().is_empty() { - Ok(()) - } else { - Err(Unspecified) - } + cipher.open(leaf.ciphertext, &leaf_aad(aad, leaf.keyset_id, &leaf.tag)) } // ============================================================================= -// Encrypt side: `Cipher` impl over a `&KeysetCipher` (builds the pending tree) +// The vitaminc ciphers: one leaf at a time, keys already in hand // ============================================================================= -impl<'c, 'k, K> Cipher for &'c KeysetCipher<'k, K> { - type Ok = PendingStackCipherText; - type Error = Unspecified; - type Passthrough = BoxedPassthrough; - type SeqCipher = PendingSeqCipher<'c, 'k, K>; - type MapCipher = PendingMapCipher<'c, 'k, K>; - - fn encrypt_bytes_vec<'a, A>( - self, - data: Protected>, - aad: A, - ) -> Result - where - A: IntoAad<'a>, - { - Ok(PendingStackCipherText::Single { - plaintext: data, - aad: aad.into_aad().into_owned(), - }) - } - - fn encrypt_seq<'a, A>(self, size_hint: Option, aad: A) -> Self::SeqCipher - where - A: IntoAad<'a>, - { - let aad = aad.into_aad().into_owned(); - PendingSeqCipher { - cipher: self, - items: Vec::with_capacity(size_hint.unwrap_or(0)), - // Both derived once here, then borrowed per element. - element_aad: aad.for_sequence_element(), - aad, - encrypted: false, - } - } - - fn encrypt_map<'a, A>(self, aad: A) -> Self::MapCipher - where - A: IntoAad<'a>, - { - PendingMapCipher { - cipher: self, - entries: Vec::new(), - seen_keys: HashSet::new(), - current_key: None, - aad: aad.into_aad().into_owned(), - encrypted: false, - } - } - - fn encrypt_none<'a, A>(self, aad: A) -> Result - where - A: IntoAad<'a>, - { - // Domain-separated so a `Single` leaf sealed under the bare AAD can - // never be re-tagged as an authenticated absence (and vice versa). - Ok(PendingStackCipherText::None { - aad: aad.into_aad().for_none(), - }) - } - - fn passthrough(self, value: Self::Passthrough) -> Result { - Ok(PendingStackCipherText::Passthrough(value)) - } - - fn passthrough_boxed( - self, - value: Box, - ) -> Result { - // This cipher's passthrough type *is* `Box`, so the - // type-erased box is already the passthrough payload. - self.passthrough(value) - } -} - -/// [`SeqCipher`] driver: accumulates a pending sub-tree per element. Holds the -/// cipher only to re-drive nested [`Encrypt`] values (no I/O happens here). -pub struct PendingSeqCipher<'c, 'k, K> { - cipher: &'c KeysetCipher<'k, K>, - items: Vec, - /// The AAD fixed at [`Cipher::encrypt_seq`]; the empty marker is sealed - /// against its `for_empty_sequence` derivation. - aad: Context<'static>, - /// [`Context::for_sequence_element`] of `aad`, derived once and applied to - /// every element. - element_aad: Context<'static>, - /// Whether at least one element went through the authenticated - /// [`encrypt_next`](SeqCipher::encrypt_next) path *and* produced a sealed - /// node — see [`end`](SeqCipher::end). - encrypted: bool, +/// The sealing half of this crate's vitaminc [`Cipher`]: seals each leaf +/// under the next of a batch of data keys ZeroKMS has already generated, +/// in the order vitaminc's [`seal_all`] visits the leaves. +/// +/// The batch is fetched before the walk, in one `generate_keys` call per +/// pending (see [`PendingStackCipherText::seal`]), so the walk itself is +/// synchronous. A leaf for which no key is left is refused. +struct MintedKeys<'k, I> { + keyset_id: Uuid, + keys: RefCell<&'k mut I>, } -impl SeqCipher for PendingSeqCipher<'_, '_, K> { - type Ok = PendingStackCipherText; +impl> Cipher for MintedKeys<'_, I> { + type Leaf = SealedValue; type Error = Unspecified; - type Passthrough = BoxedPassthrough; - fn encrypt_next(mut self, data: T) -> Result - where - T: Encrypt, - { - // Borrow the stored derived AAD — no allocation per element. - let pending = data.encrypt_with_aad(self.cipher, self.element_aad.clone())?; - // A nested `Encrypt` impl may route through the passthrough channel; - // only a genuinely pending-sealed node may satisfy `end`'s - // all-passthrough rejection. - self.encrypted |= !matches!(pending, PendingStackCipherText::Passthrough(_)); - self.items.push(pending); - Ok(self) - } - - fn passthrough_next(mut self, value: Self::Passthrough) -> Result { - self.items.push(PendingStackCipherText::Passthrough(value)); - Ok(self) + fn seal( + &self, + plaintext: Protected>, + aad: &Context<'_>, + ) -> Result { + let key = self.keys.borrow_mut().next().ok_or(Unspecified)?; + seal_leaf(plaintext, aad, self.keyset_id, key) } - fn end(self) -> Result { - if self.items.is_empty() { - Ok(PendingStackCipherText::EmptySequence { - aad: self.aad.for_empty_sequence(), - }) - } else if !self.encrypted { - // Every item is a passthrough: nothing in the container would - // authenticate the AAD, so refuse to produce it — mirroring - // `decrypt_tree`'s rejection on open. - Err(Unspecified) - } else { - Ok(PendingStackCipherText::Sequence(self.items)) - } + /// Opening goes through [`BoundKeys`], whose leaves carry their + /// retrieved keys. A minted key is never used to open. + fn open(&self, _: SealedValue, _: &Context<'_>) -> Result>, Unspecified> { + Err(Unspecified) } } -/// [`MapCipher`] driver: keys are stored in the clear; values become pending -/// sub-trees sealed against [`Context::for_map_entry`] of the map AAD and their -/// key. Mirrors `AesMapCipher`'s key/value and duplicate-key contract checks. -pub struct PendingMapCipher<'c, 'k, K> { - cipher: &'c KeysetCipher<'k, K>, - entries: Vec<(String, PendingStackCipherText)>, - /// Duplicate keys are rejected at encrypt time: `decrypt_tree` rejects - /// them outright, so accepting one here would produce a permanently - /// unreadable ciphertext. - seen_keys: HashSet, - current_key: Option>, - /// The AAD fixed at [`Cipher::encrypt_map`]. - aad: Context<'static>, - /// See [`PendingSeqCipher::encrypted`]. - encrypted: bool, -} +/// The opening half of this crate's vitaminc [`Cipher`]: opens a leaf +/// whose retrieved data key [`bind_keys`] has already bound to it, under +/// the AAD vitaminc's walk derives for the leaf's place. vitaminc then +/// checks the empty markers and the container rules and hands each +/// scalar to the sink, so the decrypt side has one definition of the +/// per-node AAD, shared with every other vitaminc cipher. +pub(crate) struct BoundKeys; -impl MapCipher for PendingMapCipher<'_, '_, K> { - type Ok = PendingStackCipherText; +impl Cipher for BoundKeys { + type Leaf = KeyedLeaf; type Error = Unspecified; - type Passthrough = BoxedPassthrough; - - fn encrypt_key(mut self, key: S) -> Result - where - S: Into>, - { - // A pending key means `encrypt_key` ran twice with no intervening - // `encrypt_value` — fail rather than silently drop the first key. - if self.current_key.is_some() { - return Err(Unspecified); - } - let key = key.into(); - if !self.seen_keys.insert(key.as_ref().to_owned()) { - return Err(Unspecified); - } - self.current_key = Some(key); - Ok(self) - } - - fn encrypt_value(mut self, value: U) -> Result - where - U: Encrypt, - { - let key = self.current_key.take().ok_or(Unspecified)?; - // Seal against PAE(domain, aad, key) — key and value are inseparable. - // `decrypt_tree` derives the same AAD on open. - let entry_aad = self.aad.for_map_entry(&key); - let pending = value.encrypt_with_aad(self.cipher, entry_aad)?; - self.encrypted |= !matches!(pending, PendingStackCipherText::Passthrough(_)); - self.entries.push((key.into_owned(), pending)); - Ok(self) - } - - fn passthrough_entry(mut self, key: S, value: Self::Passthrough) -> Result - where - S: Into>, - { - // Adopting a pending key here would silently drop it — same contract - // violation `encrypt_key` rejects. - if self.current_key.is_some() { - return Err(Unspecified); - } - let key = key.into(); - if !self.seen_keys.insert(key.as_ref().to_owned()) { - return Err(Unspecified); - } - self.entries - .push((key.into_owned(), PendingStackCipherText::Passthrough(value))); - Ok(self) - } - - fn passthrough_entry_boxed( - self, - key: S, - value: Box, - ) -> Result - where - S: Into>, - { - // This cipher's passthrough type *is* `Box`, so the - // type-erased box is already the payload — same as `passthrough_boxed`. - self.passthrough_entry(key, value) - } - fn end(self) -> Result { - // Finalising with a pending key would silently drop the entry. - if self.current_key.is_some() { - return Err(Unspecified); - } - if self.entries.is_empty() { - Ok(PendingStackCipherText::EmptyMap { - aad: self.aad.for_empty_map(), - }) - } else if !self.encrypted { - // Every entry is a passthrough — see `PendingSeqCipher::end`. - Err(Unspecified) - } else { - Ok(PendingStackCipherText::Map(self.entries)) - } - } -} - -// Decrypt side: a synchronous `Decipher` over a key-bound ciphertext tree -// ============================================================================= - -/// A [`Decipher`] over a single [`StackCipherText`] whose leaves already -/// carry their retrieved data keys, built inside -/// [`StackCipher::decrypt`] and driven there by the value's [`Decrypt`] -/// impl under the same context the keys were retrieved with. -/// -/// Structurally identical to `vitaminc_encrypt::AesDecipher` — the only -/// difference is where each leaf's key comes from. The AAD is supplied per call -/// by [`Decrypt::decrypt_with_aad`] and refined here exactly as the encrypt side -/// refined it: sequence elements under [`Context::for_sequence_element`], map -/// values under [`Context::for_map_entry`] of their key, markers under their -/// respective derivations with an enforced-empty plaintext. Because the -/// derivation lives in this drive (not in a pre-pass), `Decrypt` impls that -/// transform the AAD themselves (e.g. `vitaminc_aead::Element`) work unchanged. -pub struct StackDecipher { - ciphertext: KeyedCipherText, -} - -impl StackDecipher { - pub(crate) fn over(ciphertext: KeyedCipherText) -> Self { - Self { ciphertext } - } - - /// Typed convenience over [`Decipher::decrypt_passthrough`] for this - /// cipher's [`BoxedPassthrough`] payload type: recovers the payload and - /// downcasts it to `T`, returning [`Unspecified`] if the ciphertext is not - /// a passthrough or the stored type does not match. - pub fn decrypt_passthrough_as(self) -> Result - where - T: Any + Send + 'static, - { - self.decrypt_passthrough()? - .downcast::() - .map(|b| *b) - .map_err(|_| Unspecified) - } -} - -impl<'c> Decipher<'c> for StackDecipher { - type Ok - = Result - where - T: Send + 'c; - - type Passthrough = BoxedPassthrough; - - fn map_ok(ok: Self::Ok, f: F) -> Self::Ok - where - T: Send + 'c, - U: Send + 'c, - F: FnOnce(T) -> U, - { - ok.map(f) - } - - fn decrypt_bytes<'a, V, A>(self, visitor: V, aad: A) -> Self::Ok - where - V: DecipherVisitor<'c> + Send + 'c, - A: IntoAad<'a>, - { - match self.ciphertext { - CipherText::Single(keyed) => { - let bytes = open_leaf(keyed, &aad.into_aad())?; - visitor.visit_bytes_vec(bytes) - } - _ => Err(Unspecified), - } - } - - fn decrypt_seq<'a, V, A>(self, visitor: V, aad: A) -> Self::Ok - where - V: DecipherVisitor<'c> + Send + 'c, - A: IntoAad<'a>, - { - match self.ciphertext { - // At least one non-passthrough item required: passthrough items - // authenticate nothing, so an all-passthrough (or entry-less) - // sequence would verify under any AAD. The encrypt side refuses to - // produce one; refuse to open one. Emptiness is only provable by - // the authenticated `EmptySequence` marker. - CipherText::Sequence(items) - if items - .iter() - .any(|i| !matches!(i, CipherText::Passthrough(_))) => - { - visitor.visit_seq(StackSeqAccess { - items: items.into_iter(), - element_aad: aad.into_aad().for_sequence_element(), - }) - } - CipherText::EmptySequence(keyed) => { - let aad = aad.into_aad(); - verify_empty_marker(keyed, &aad.for_empty_sequence())?; - // Store the element derivation exactly as the non-empty arm - // does: never read (the iterator is empty), but a divergent - // value here would silently break a visitor that consulted it. - visitor.visit_seq(StackSeqAccess { - items: Vec::new().into_iter(), - element_aad: aad.for_sequence_element(), - }) - } - _ => Err(Unspecified), - } - } - - fn decrypt_map<'a, V, A>(self, visitor: V, aad: A) -> Self::Ok - where - V: DecipherVisitor<'c> + Send + 'c, - A: IntoAad<'a>, - { - match self.ciphertext { - // At least one non-passthrough entry required — see `decrypt_seq`. - CipherText::Map(entries) - if entries - .iter() - .any(|(_, v)| !matches!(v, CipherText::Passthrough(_))) => - { - // Reject duplicate keys before the visitor sees any entry: two - // ciphertexts of the same logical record seal a given key's - // value against the identical `for_map_entry` AAD, so a stale - // entry appended to a current ciphertext *verifies* — with a - // last-wins visitor that is a single-field rollback. - let mut seen = HashSet::with_capacity(entries.len()); - if !entries.iter().all(|(key, _)| seen.insert(key.as_str())) { - return Err(Unspecified); - } - visitor.visit_map(StackMapAccess { - entries: entries.into_iter(), - aad: aad.into_aad(), - pending: None, - }) - } - CipherText::EmptyMap(keyed) => { - let aad = aad.into_aad(); - verify_empty_marker(keyed, &aad.for_empty_map())?; - // Raw caller AAD, not the marker derivation — see `decrypt_seq`. - visitor.visit_map(StackMapAccess { - entries: Vec::new().into_iter(), - aad, - pending: None, - }) - } - _ => Err(Unspecified), - } - } - - fn decrypt_any<'a, V, A>(self, visitor: V, aad: A) -> Self::Ok - where - V: DecipherVisitor<'c> + Send + 'c, - A: IntoAad<'a>, - { - match self.ciphertext { - ct @ CipherText::Single(_) => Self::over(ct).decrypt_bytes(visitor, aad), - ct @ (CipherText::Sequence(_) | CipherText::EmptySequence(_)) => { - Self::over(ct).decrypt_seq(visitor, aad) - } - ct @ (CipherText::Map(_) | CipherText::EmptyMap(_)) => { - Self::over(ct).decrypt_map(visitor, aad) - } - CipherText::None(keyed) => { - // Verify the domain-separated marker (tag AND empty plaintext) - // before reporting absence — an unauthenticated `visit_none` - // would let an attacker forge "absent" values, and a bare-AAD - // check would let a `Single` leaf be re-tagged as one. Mirrors - // `decrypt_option`. - verify_empty_marker(keyed, &aad.into_aad().for_none())?; - visitor.visit_none() - } - // A self-describing visitor recovers a passthrough via - // `visit_passthrough` (type-erased); visitors that do not override - // it inherit the default rejection. - CipherText::Passthrough(boxed) => visitor.visit_passthrough(boxed), - } + /// Sealing goes through [`MintedKeys`]: a leaf is sealed under a key + /// generated for it, never one retrieved for another leaf. + fn seal(&self, _: Protected>, _: &Context<'_>) -> Result { + Err(Unspecified) } - fn decrypt_passthrough(self) -> Self::Ok { - match self.ciphertext { - CipherText::Passthrough(boxed) => Ok(boxed), - _ => Err(Unspecified), - } + fn open(&self, leaf: KeyedLeaf, aad: &Context<'_>) -> Result>, Unspecified> { + open_leaf(leaf, aad) } - - fn decrypt_option<'a, T, A>(self, aad: A) -> Self::Ok> - where - T: Decrypt<'c> + 'c, - A: IntoAad<'a>, - { - match self.ciphertext { - CipherText::None(keyed) => { - // Verify the tag over the domain-separated marker AAD AND that - // the sealed plaintext is actually empty. Without both, a - // `Single(leaf)` sealed under the same caller AAD could be - // re-tagged as `None(leaf)` and decrypt as Ok(None) — silent - // authenticated data deletion. - verify_empty_marker(keyed, &aad.into_aad().for_none())?; - Ok(None) - } - // Passthrough must never be decoded as an Option payload. - CipherText::Passthrough(_) => Err(Unspecified), - // Any other variant is the `Some` payload: recurse into `T` with - // the caller's AAD unchanged (there is no depth tag; the shape of - // nested options is decided by `T` at the call site, as in - // `AesDecipher`). - other => T::decrypt_with_aad(Self::over(other), aad).map(Some), - } - } -} - -struct StackSeqAccess { - items: std::vec::IntoIter, - /// [`Context::for_sequence_element`] of the caller's AAD, derived once at - /// construction and re-supplied per element by borrowing. Mirrors - /// `PendingSeqCipher::element_aad` on the encrypt side. - element_aad: Context<'static>, } -impl<'c> SeqAccess<'c> for StackSeqAccess { - type Error = Unspecified; - - fn next_element + 'c>(&mut self) -> Result, Self::Error> { - match self.items.next() { - Some(ct) => { - T::decrypt_with_aad(StackDecipher::over(ct), self.element_aad.clone()).map(Some) - } - None => Ok(None), - } - } -} - -struct StackMapAccess<'a> { - entries: std::vec::IntoIter<(String, KeyedCipherText)>, - aad: Context<'a>, - /// The entry handed out by `next_key` and not yet consumed by - /// `next_value` / `next_passthrough`. Held as ciphertext rather than - /// decrypted up front so the caller can choose the plaintext type after - /// seeing the key — see [`MapAccess::next_key`] — alongside the entry - /// AAD it was sealed under, derived once here so the key itself moves - /// out to the caller. - pending: Option<(Context<'static>, KeyedCipherText)>, -} - -impl<'c, 'a> MapAccess<'c> for StackMapAccess<'a> { - type Error = Unspecified; - - fn next_key(&mut self) -> Result, Self::Error> { - // A still-pending entry means the caller skipped a value. Refused - // rather than tolerated: an entry whose value is never opened is an - // entry whose AAD binding is never verified. - if self.pending.is_some() { - return Err(Unspecified); - } - match self.entries.next() { - Some((key, ct)) => { - // Mirror `PendingMapCipher::encrypt_value`: the value was - // sealed against `for_map_entry(key)`, so a swapped or - // renamed key fails when the entry is opened. - self.pending = Some((self.aad.for_map_entry(&key), ct)); - Ok(Some(key)) - } - None => Ok(None), - } - } - - fn next_value + 'c>(&mut self) -> Result { - let (entry_aad, ct) = self.pending.take().ok_or(Unspecified)?; - T::decrypt_with_aad(StackDecipher::over(ct), entry_aad) - } - - fn next_passthrough(&mut self) -> Result, Self::Error> { - match self.pending.take() { - Some((_key, CipherText::Passthrough(value))) => Ok(value), - // A sealed value under a key the caller asked to read as a - // passthrough: refuse rather than hand it back with its tag - // unchecked — but keep the entry pending. The refusal is the - // caller's answer, not a reason to lose the entry: it can still - // open it with `next_value`, and until it does `next_key` keeps - // refusing to move past it. - Some(entry) => { - self.pending = Some(entry); - Err(Unspecified) - } - None => Err(Unspecified), - } - } +/// Open a key-bound tree into `T` under `aad`. Every failure is +/// [`Error::Aead`]: a wrong key or AAD, a tampered leaf or marker, a +/// malformed tree, and a value that is not a `T` all read the same to the +/// caller, as they did before vitaminc split them. +pub(crate) fn open_keyed<'a, T: Decrypt>( + keyed: KeyedCipherText, + aad: impl IntoAad<'a>, +) -> Result { + vitaminc_aead::decrypt(&BoundKeys, keyed, aad).map_err(|_| Error::Aead) } #[cfg(test)] mod tests { use super::*; - fn map_access(entries: Vec<(&str, KeyedCipherText)>) -> StackMapAccess<'static> { - StackMapAccess { - entries: entries - .into_iter() - .map(|(key, ct)| (key.to_string(), ct)) - .collect::>() - .into_iter(), - aad: Context::from_encoded(b"map"), - pending: None, - } - } - - #[test] - fn a_passthrough_entry_is_read_back_as_the_boxed_value() { - let mut map = map_access(vec![("plain", CipherText::Passthrough(Box::new(7u32)))]); - - assert_eq!(map.next_key(), Ok(Some("plain".to_string()))); - let value = map.next_passthrough().expect("passthrough entry"); - assert_eq!(value.downcast_ref::(), Some(&7)); - assert_eq!(map.next_key(), Ok(None)); - } - - /// Asking for a sealed entry as a passthrough is refused, and the entry - /// stays pending: it is neither handed back unverified nor lost, so the - /// caller can still open it with `next_value` and cannot skip it. - #[test] - fn a_sealed_entry_survives_being_misread_as_a_passthrough() { - let mut map = map_access(vec![ - ("sealed", CipherText::Sequence(vec![])), - ("plain", CipherText::Passthrough(Box::new(7u32))), - ]); - - assert_eq!(map.next_key(), Ok(Some("sealed".to_string()))); - assert!(map.next_passthrough().is_err()); - // Still pending: the map refuses to advance past an unopened entry. - assert_eq!(map.next_key(), Err(Unspecified)); - // And the refusal is repeatable, not a one-shot that then drops it. - assert!(map.next_passthrough().is_err()); - assert_eq!(map.next_key(), Err(Unspecified)); - } - - /// A draw with nothing pending — before any `next_key`, or after the - /// entry has already been taken — is a clean refusal, never a value and - /// never a panic. This is the guard that keeps an absent or unverified - /// value from being handed back, so a refactor from `ok_or(..)?` to an - /// `unwrap` would regress silently without it. - #[test] - fn drawing_a_value_with_nothing_pending_is_refused() { - let mut map = map_access(vec![("plain", CipherText::Passthrough(Box::new(7u32)))]); - - // No `next_key` yet: nothing is pending. - assert!(map.next_value::().is_err()); - assert!(map.next_passthrough().is_err()); - - // A legitimate draw consumes the entry, so a second draw of either - // kind is refused too. - assert_eq!(map.next_key(), Ok(Some("plain".to_string()))); - assert!(map.next_passthrough().is_ok()); - assert!(map.next_passthrough().is_err()); - assert!(map.next_value::().is_err()); - assert_eq!(map.next_key(), Ok(None)); - } - /// A cipher's `Debug` — and a keyset handle's — names the keyset and the /// source's type and stops there: the cache behind a cipher holds every /// loaded keyset's index key, a handle carries its keyset's, and the @@ -2145,27 +1549,26 @@ mod tests { } } - /// `decrypt_passthrough` hands back the payload of a passthrough node and - /// nothing else: any sealed shape is refused rather than surfaced - /// unopened, and a payload of another type is refused by the downcast. + /// Each half of the cipher does one thing: a minted key never opens a + /// leaf, and a key bound to a leaf for opening never seals one. #[test] - fn decrypt_passthrough_opens_only_a_passthrough() { - let passthrough = || StackDecipher::over(CipherText::Passthrough(Box::new(7u32))); - - assert_eq!( - passthrough().decrypt_passthrough_as::(), - Ok(7), - "a passthrough payload of the requested type should be handed back" - ); - assert_eq!( - passthrough().decrypt_passthrough_as::(), - Err(Unspecified), - "a payload of another type should fail the downcast" + fn each_half_of_the_cipher_refuses_the_other_halfs_work() { + let leaf = + SealedValue::from_parts(Uuid::nil(), [0; 16], vec![1; 4], vec![2; 32]).expect("a leaf"); + let mut keys = std::iter::empty::(); + let minted = MintedKeys { + keyset_id: Uuid::nil(), + keys: RefCell::new(&mut keys), + }; + let aad = Context::from_encoded(b"aad"); + assert!(minted.open(leaf, &aad).is_err(), "a minted key never opens"); + assert!( + minted.seal(Protected::new(vec![1]), &aad).is_err(), + "and seals nothing once the batch is spent" ); - assert_eq!( - StackDecipher::over(CipherText::Sequence(vec![])).decrypt_passthrough_as::(), - Err(Unspecified), - "a sealed shape should be refused rather than surfaced unopened" + assert!( + BoundKeys.seal(Protected::new(vec![1]), &aad).is_err(), + "a bound key never seals" ); } diff --git a/packages/stack-encrypt/src/dynamic/context.rs b/packages/stack-encrypt/src/dynamic/context.rs index 49c983222..160c4a15b 100644 --- a/packages/stack-encrypt/src/dynamic/context.rs +++ b/packages/stack-encrypt/src/dynamic/context.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; -use vitaminc_aead_value::FfiValue; +use super::FfiValue; use vitaminc_protected::Controlled; use super::{Error, Reason}; @@ -36,7 +36,7 @@ use crate::{ContextPiece, NonEmpty}; /// /// A bare string is one text part. An array is a list, and may nest as deep /// as the transport codec allows -/// ([`MAX_DEPTH`](vitaminc_aead_value::transport::MAX_DEPTH) levels, counted +/// ([`MAX_DEPTH`](vitaminc_aead::frame::MAX_DEPTH) levels, counted /// from the root of the encoded value); a deeper value is refused by the /// codec before this module sees it. Text and bytes with the same content /// are distinct contexts (UTF-8 versus bytes typed leaves) — the same diff --git a/packages/stack-encrypt/src/dynamic/ffi_value.rs b/packages/stack-encrypt/src/dynamic/ffi_value.rs new file mode 100644 index 000000000..34c7ebc95 --- /dev/null +++ b/packages/stack-encrypt/src/dynamic/ffi_value.rs @@ -0,0 +1,735 @@ +//! The runtime value tree every binding funnels through: [`FfiValue`], and +//! the kinds a plan field declares ([`ValueKind`]). +//! +//! Both were `vitaminc-aead-value`'s until vitaminc ADR 0001 reduced that +//! crate to the transport (`vitaminc-aead-frame`). A value now describes +//! itself to vitaminc through [`Encrypt`] and is read back through a +//! [`Decrypt`] sink, so an [`FfiValue`] seals the same tagged leaves a +//! typed Rust value of the same kind does, and crosses an FFI boundary as a +//! transport frame ([`to_frame`](FfiValue::to_frame), +//! [`from_frame`](FfiValue::from_frame)). The tree is kept here, unchanged +//! in shape, so the engine and the bindings built on it keep their +//! behaviour while they move onto vitaminc's model. + +use std::collections::BTreeSet; +use std::fmt; +use std::str::FromStr; + +use vitaminc_aead::frame::{FrameError, FrameWriteError}; +use vitaminc_aead::plaintext::{ + visit_map, visit_seq, Error as _, Float, Int, Logical, MapSink, Physical, SeqSink, Sink, + SourceError, +}; +use vitaminc_aead::{to_frame, DecodeError, Decrypt, Encrypt, Frame}; +use vitaminc_protected::{Controlled, Protected}; +use zeroize::Zeroize; + +/// An owned, `Send`, language-neutral representation of a dynamically typed +/// host value: what a binding converts a host value into on the host's +/// thread, so encryption and decryption can run on any thread. +/// Secret-bearing leaves (strings and bytes) are held in [`Protected`] so +/// the Rust-side copies are wiped on drop; copies owned by the host +/// runtime's heap cannot be wiped from here. +/// +/// Each variant is one of vitaminc's scalars or containers: `Null` is a +/// null, `Undefined` a null under the `Undefined` logical type, the +/// numeric variants are ints and floats of their width, `String` is text, +/// `Bytes` bytes, `Array` a sequence, `Object` a map, and `Passthrough` a +/// subtree that travels in the clear. So a value seals as the leaves a +/// typed Rust value of the same kind seals as: `UInt32(34)` and `34u32` +/// are the same ciphertext under the same context. +#[derive(Zeroize)] +pub enum FfiValue { + /// Null (JS `null`, Python `None`, Go `nil`). + Null, + /// JavaScript `undefined`. Languages without an analog decode this to + /// their null value and never encode it. + Undefined, + /// Boolean. + Bool(bool), + /// 32-bit signed integer. + Int32(i32), + /// 64-bit signed integer. + Int64(i64), + /// 32-bit unsigned integer. + UInt32(u32), + /// 64-bit unsigned integer. + UInt64(u64), + /// 32-bit floating-point number. Round-trips by IEEE-754 bit pattern. + Float32(f32), + /// 64-bit floating-point number. Round-trips by IEEE-754 bit pattern. + Float64(f64), + /// String as UTF-8 bytes, validated at construction ([`Utf8String`]). + String(Utf8String), + /// Binary data. + Bytes(Protected>), + /// Array: a sequence. + Array(Vec), + /// String-keyed object: a map. Keys travel in the clear, bound into + /// each value's AAD. + Object(Vec<(String, FfiValue)>), + /// A subtree that travels alongside the ciphertext **unencrypted and + /// unauthenticated**. Non-sensitive fields only: anyone holding the + /// ciphertext can read and alter it. + // Skipped by `Zeroize`: the payload is non-sensitive by contract (it is + // sent in the clear), and `Box` is not `Zeroize` anyway. + #[zeroize(skip)] + Passthrough(Box), +} + +/// UTF-8 string payload for [`FfiValue::String`], validated at construction. +/// +/// Wraps `Protected>` with the invariant that the bytes are valid +/// UTF-8, so a `String` leaf can never seal bytes that would not read back +/// as text. +#[derive(Zeroize)] +pub struct Utf8String(Protected>); + +impl Utf8String { + /// The validated UTF-8 bytes. Same chain-of-custody caveats as + /// [`Protected::risky_ref`]: the reference must not outlive its use. + pub fn risky_ref(&self) -> &[u8] { + self.0.risky_ref() + } + + /// Unwrap to the protected byte payload (still valid UTF-8; the + /// invariant is dropped with the type). + pub fn into_inner(self) -> Protected> { + self.0 + } + + /// The text. `None` cannot happen while the invariant holds; it is + /// checked rather than assumed because this is boundary code. + fn as_str(&self) -> Option<&str> { + std::str::from_utf8(self.0.risky_ref()).ok() + } +} + +impl From for Utf8String { + fn from(s: String) -> Self { + Self(Protected::new(s.into_bytes())) + } +} + +impl From<&str> for Utf8String { + fn from(s: &str) -> Self { + Self(Protected::new(s.as_bytes().to_vec())) + } +} + +impl TryFrom>> for Utf8String { + type Error = vitaminc_aead::Unspecified; + + /// Validates the bytes; on failure the rejected payload is dropped (and + /// wiped) here. + fn try_from(bytes: Protected>) -> Result { + let _ = std::str::from_utf8(bytes.risky_ref()).map_err(|_| vitaminc_aead::Unspecified)?; + Ok(Self(bytes)) + } +} + +impl FfiValue { + /// This value as a transport frame: what a binding hands its host. + /// + /// # Errors + /// + /// [`FrameWriteError`] for a value nested deeper than the frame + /// carries, or an object with a repeated key. + pub fn to_frame(&self) -> Result>, FrameWriteError> { + to_frame(self) + } + + /// Read one value from a transport frame: what a host hands a binding. + /// + /// # Errors + /// + /// [`FromFrameError`] when the bytes are not exactly one frame, or the + /// frame holds a scalar no variant holds (an 8-bit integer, a date). + pub fn from_frame(bytes: &[u8]) -> Result { + let frame = Frame::parse(bytes).map_err(FromFrameError::Frame)?; + frame.visit(Self::sink()).map_err(FromFrameError::Value) + } +} + +/// Why [`FfiValue::from_frame`] refused its bytes. +#[derive(Debug, thiserror::Error)] +pub enum FromFrameError { + /// The bytes are not exactly one well-formed frame. + #[error(transparent)] + Frame(FrameError), + /// The frame holds a value no [`FfiValue`] variant holds. + #[error(transparent)] + Value(DecodeError), +} + +/// Walks the value, leaving it where it is. Text and bytes are handed to +/// the sink as borrows of the `Protected` payloads. +impl Encrypt for &FfiValue { + fn visit(self, sink: S) -> Result { + match self { + FfiValue::Null => sink.scalar(None, Physical::Null), + FfiValue::Undefined => sink.scalar(Some(Logical::Undefined), Physical::Null), + FfiValue::Bool(b) => sink.scalar(None, Physical::Bool(*b)), + FfiValue::Int32(i) => sink.scalar(None, Physical::Int(Int::I32(*i))), + FfiValue::Int64(i) => sink.scalar(None, Physical::Int(Int::I64(*i))), + FfiValue::UInt32(u) => sink.scalar(None, Physical::Int(Int::U32(*u))), + FfiValue::UInt64(u) => sink.scalar(None, Physical::Int(Int::U64(*u))), + FfiValue::Float32(f) => sink.scalar(None, Physical::Float(Float::F32(*f))), + FfiValue::Float64(f) => sink.scalar(None, Physical::Float(Float::F64(*f))), + FfiValue::String(s) => match s.as_str() { + Some(text) => sink.scalar(None, Physical::Text(text)), + None => Err(S::Error::source(SourceError::msg("a string is not UTF-8"))), + }, + FfiValue::Bytes(b) => sink.scalar(None, Physical::Bytes(b.risky_ref())), + FfiValue::Array(items) => visit_seq(sink, Some(items.len()), items), + FfiValue::Object(entries) => visit_map( + sink, + Some(entries.len()), + entries.iter().map(|(key, value)| (key.as_str(), value)), + ), + FfiValue::Passthrough(inner) => sink.passthrough(&**inner), + } + } +} + +/// Walks the value by reference, then drops it, which wipes its text and +/// bytes. +impl Encrypt for FfiValue { + fn visit(self, sink: S) -> Result { + (&self).visit(sink) + } +} + +impl Decrypt for FfiValue { + type Sink = ValueSink; + fn sink() -> Self::Sink { + ValueSink(Root) + } +} + +/// Where a finished value goes: back to the caller, or into its parent. +pub trait Put { + /// What putting a value produces. + type Ok; + /// Put `value` in place. + fn put(self, value: FfiValue) -> Self::Ok; +} + +/// The top of a walk: the value is the result. +pub struct Root; + +impl Put for Root { + type Ok = FfiValue; + fn put(self, value: FfiValue) -> FfiValue { + value + } +} + +/// The next element of an array under construction. +pub struct Element<'s>(&'s mut Vec); + +impl Put for Element<'_> { + type Ok = (); + fn put(self, value: FfiValue) { + self.0.push(value); + } +} + +/// One entry of an object under construction. +pub struct Entry<'s>(&'s mut Vec<(String, FfiValue)>, String); + +impl Put for Entry<'_> { + type Ok = (); + fn put(self, value: FfiValue) { + self.0.push((self.1, value)); + } +} + +/// The [`Decrypt`] sink of an [`FfiValue`]: builds the value a walk +/// describes. An 8- or 16-bit integer reads as the 32-bit variant of its +/// sign, which holds it exactly: a host with narrower integers (Go's `int8`, +/// say) sends them at their own width, and the value model has no variant +/// for them. A scalar no variant holds (a 128-bit integer, or a logical +/// type other than `Undefined`) is refused. +pub struct ValueSink(O); + +impl ValueSink { + /// A sink that puts the value it builds into `out`. + pub fn new(out: O) -> Self { + Self(out) + } +} + +/// What [`ValueSink`] refuses a scalar with. +const EXPECTED: &str = "a null, bool, int of at most 64 bits, float, string or bytes"; + +impl Sink for ValueSink { + type Ok = O::Ok; + type Error = DecodeError; + type Seq = ValueSeq; + type Map = ValueMap; + + fn scalar(self, ty: Option, value: Physical<'_>) -> Result { + let value = match (ty, value) { + (None, Physical::Null) => FfiValue::Null, + (Some(Logical::Undefined), Physical::Null) => FfiValue::Undefined, + (None, Physical::Bool(b)) => FfiValue::Bool(b), + (None, Physical::Int(Int::I8(i))) => FfiValue::Int32(i.into()), + (None, Physical::Int(Int::I16(i))) => FfiValue::Int32(i.into()), + (None, Physical::Int(Int::I32(i))) => FfiValue::Int32(i), + (None, Physical::Int(Int::I64(i))) => FfiValue::Int64(i), + (None, Physical::Int(Int::U8(u))) => FfiValue::UInt32(u.into()), + (None, Physical::Int(Int::U16(u))) => FfiValue::UInt32(u.into()), + (None, Physical::Int(Int::U32(u))) => FfiValue::UInt32(u), + (None, Physical::Int(Int::U64(u))) => FfiValue::UInt64(u), + (None, Physical::Float(Float::F32(f))) => FfiValue::Float32(f), + (None, Physical::Float(Float::F64(f))) => FfiValue::Float64(f), + (None, Physical::Text(text)) => FfiValue::String(text.into()), + (None, Physical::Bytes(bytes)) => FfiValue::Bytes(Protected::new(bytes.to_vec())), + _ => return Err(DecodeError::Expected(EXPECTED)), + }; + Ok(self.0.put(value)) + } + + fn seq(self, _: Option) -> Result, DecodeError> { + Ok(ValueSeq { + out: self.0, + items: Vec::new(), + }) + } + + fn map(self, _: Option) -> Result, DecodeError> { + Ok(ValueMap { + out: self.0, + entries: Vec::new(), + keys: BTreeSet::new(), + }) + } + + fn passthrough(self, value: P) -> Result { + let inner = value.visit(ValueSink(Root))?; + Ok(self.0.put(FfiValue::Passthrough(Box::new(inner)))) + } +} + +/// The sequence sink of [`ValueSink`]. +pub struct ValueSeq { + out: O, + items: Vec, +} + +impl SeqSink for ValueSeq { + type Ok = O::Ok; + type Error = DecodeError; + type Element<'s> + = ValueSink> + where + Self: 's; + + fn element(&mut self) -> Result>, DecodeError> { + Ok(ValueSink(Element(&mut self.items))) + } + + fn end(self) -> Result { + Ok(self.out.put(FfiValue::Array(self.items))) + } +} + +/// The map sink of [`ValueSink`]. A repeated key is refused, as an object +/// holds each key once. +pub struct ValueMap { + out: O, + entries: Vec<(String, FfiValue)>, + /// The keys seen so far, so the repeated-key check is a lookup rather + /// than a scan of `entries`: a frame is host input, and a scan made an + /// object of `n` keys cost `n²` comparisons. Ordered rather than hashed, + /// so no key can be chosen to collide. + keys: BTreeSet, +} + +impl MapSink for ValueMap { + type Ok = O::Ok; + type Error = DecodeError; + type Entry<'s> + = ValueSink> + where + Self: 's; + + fn entry(&mut self, key: &str) -> Result>, DecodeError> { + if !self.keys.insert(key.to_owned()) { + return Err(DecodeError::duplicate_key(key)); + } + Ok(ValueSink(Entry(&mut self.entries, key.to_owned()))) + } + + fn end(self) -> Result { + Ok(self.out.put(FfiValue::Object(self.entries))) + } +} + +#[cfg(test)] +impl PartialEq for FfiValue { + /// Structural equality for tests only. Variable-time, which is why + /// this is `cfg(test)`. Floats compare by raw bit pattern so `-0.0` and + /// `NaN` payloads compare as they seal. + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (FfiValue::Null, FfiValue::Null) => true, + (FfiValue::Undefined, FfiValue::Undefined) => true, + (FfiValue::Bool(a), FfiValue::Bool(b)) => a == b, + (FfiValue::Int32(a), FfiValue::Int32(b)) => a == b, + (FfiValue::Int64(a), FfiValue::Int64(b)) => a == b, + (FfiValue::UInt32(a), FfiValue::UInt32(b)) => a == b, + (FfiValue::UInt64(a), FfiValue::UInt64(b)) => a == b, + (FfiValue::Float32(a), FfiValue::Float32(b)) => a.to_bits() == b.to_bits(), + (FfiValue::Float64(a), FfiValue::Float64(b)) => a.to_bits() == b.to_bits(), + (FfiValue::String(a), FfiValue::String(b)) => a.risky_ref() == b.risky_ref(), + (FfiValue::Bytes(a), FfiValue::Bytes(b)) => a.risky_ref() == b.risky_ref(), + (FfiValue::Array(a), FfiValue::Array(b)) => a == b, + (FfiValue::Object(a), FfiValue::Object(b)) => a == b, + (FfiValue::Passthrough(a), FfiValue::Passthrough(b)) => a == b, + _ => false, + } + } +} + +/// Names the variant and nothing more of a string or bytes: the value is +/// plaintext. +impl fmt::Debug for FfiValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FfiValue::Null => f.write_str("Null"), + FfiValue::Undefined => f.write_str("Undefined"), + FfiValue::Bool(b) => write!(f, "Bool({b})"), + FfiValue::Int32(i) => write!(f, "Int32({i})"), + FfiValue::Int64(i) => write!(f, "Int64({i})"), + FfiValue::UInt32(u) => write!(f, "UInt32({u})"), + FfiValue::UInt64(u) => write!(f, "UInt64({u})"), + FfiValue::Float32(n) => write!(f, "Float32({n})"), + FfiValue::Float64(n) => write!(f, "Float64({n})"), + FfiValue::String(_) => f.write_str("String()"), + FfiValue::Bytes(_) => f.write_str("Bytes()"), + FfiValue::Array(items) => f.debug_tuple("Array").field(items).finish(), + FfiValue::Object(entries) => f.debug_tuple("Object").field(entries).finish(), + FfiValue::Passthrough(inner) => f.debug_tuple("Passthrough").field(inner).finish(), + } + } +} + +/// The kind of an [`FfiValue`]: its variant, without a payload. This is the +/// type vocabulary a plan field declares in its `"type"` key. +/// +/// `Null`, `Undefined` and `Passthrough` have no kind: each null is a type +/// with one value, so declaring a field as one says nothing, and a +/// passthrough is a transport choice, not a type. +/// +/// # Names are wire format +/// +/// [`name`](Self::name) (also its [`Display`](fmt::Display) and [`FromStr`] +/// form) is how a binding spells a kind. The names are frozen: matched +/// exactly, case-sensitively, and never renamed. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum ValueKind { + /// `"bool"`. + Bool, + /// `"int32"`. + Int32, + /// `"int64"`. + Int64, + /// `"uint32"`. + UInt32, + /// `"uint64"`. + UInt64, + /// `"float32"`. + Float32, + /// `"float64"`. + Float64, + /// `"string"`. + String, + /// `"bytes"`. + Bytes, + /// `"array"`. Says nothing about the elements' kinds. + Array, + /// `"object"`. Says nothing about the entries' kinds. + Object, +} + +impl ValueKind { + /// Every kind, in declaration order. + pub const ALL: [ValueKind; 11] = [ + ValueKind::Bool, + ValueKind::Int32, + ValueKind::Int64, + ValueKind::UInt32, + ValueKind::UInt64, + ValueKind::Float32, + ValueKind::Float64, + ValueKind::String, + ValueKind::Bytes, + ValueKind::Array, + ValueKind::Object, + ]; + + /// How a declaration spells this kind: `"bool"`, `"int32"`, `"int64"`, + /// `"uint32"`, `"uint64"`, `"float32"`, `"float64"`, `"string"`, + /// `"bytes"`, `"array"` or `"object"`. Frozen wire format. + pub const fn name(self) -> &'static str { + match self { + ValueKind::Bool => "bool", + ValueKind::Int32 => "int32", + ValueKind::Int64 => "int64", + ValueKind::UInt32 => "uint32", + ValueKind::UInt64 => "uint64", + ValueKind::Float32 => "float32", + ValueKind::Float64 => "float64", + ValueKind::String => "string", + ValueKind::Bytes => "bytes", + ValueKind::Array => "array", + ValueKind::Object => "object", + } + } + + /// Whether `value` is of this kind. Exact: an `Int64` is not held by + /// [`UInt64`](Self::UInt64), and a [`FfiValue::Passthrough`] is held by + /// no kind, whatever it wraps. + pub fn holds(self, value: &FfiValue) -> bool { + value.kind() == Some(self) + } +} + +impl fmt::Display for ValueKind { + /// Writes [`name`](Self::name). + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +impl FromStr for ValueKind { + type Err = ParseValueKindError; + + /// Parses a [`name`](Self::name), exactly: `"uint64"`, not `"UInt64"`, + /// `"u64"` or `" uint64"`. `"null"` and `"undefined"` are not kinds. + fn from_str(name: &str) -> Result { + ValueKind::ALL + .into_iter() + .find(|kind| kind.name() == name) + .ok_or(ParseValueKindError) + } +} + +/// The error from parsing a string that is not a [`ValueKind::name`]. +/// Carries no detail: the caller holds the string it tried to parse. +#[derive(Clone, Copy, PartialEq, Eq, Debug, thiserror::Error)] +#[error("not a value kind")] +pub struct ParseValueKindError; + +impl FfiValue { + /// This value's [`ValueKind`], or `None` for [`Null`](FfiValue::Null), + /// [`Undefined`](FfiValue::Undefined) and + /// [`Passthrough`](FfiValue::Passthrough). + pub fn kind(&self) -> Option { + // Exhaustive on purpose: a new variant must decide its kind here. + match self { + FfiValue::Bool(_) => Some(ValueKind::Bool), + FfiValue::Int32(_) => Some(ValueKind::Int32), + FfiValue::Int64(_) => Some(ValueKind::Int64), + FfiValue::UInt32(_) => Some(ValueKind::UInt32), + FfiValue::UInt64(_) => Some(ValueKind::UInt64), + FfiValue::Float32(_) => Some(ValueKind::Float32), + FfiValue::Float64(_) => Some(ValueKind::Float64), + FfiValue::String(_) => Some(ValueKind::String), + FfiValue::Bytes(_) => Some(ValueKind::Bytes), + FfiValue::Array(_) => Some(ValueKind::Array), + FfiValue::Object(_) => Some(ValueKind::Object), + FfiValue::Null | FfiValue::Undefined | FfiValue::Passthrough(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn s(value: &str) -> FfiValue { + FfiValue::String(value.into()) + } + + fn every_variant() -> FfiValue { + FfiValue::Object(vec![ + ("n".to_string(), FfiValue::Null), + ("u".to_string(), FfiValue::Undefined), + ("b".to_string(), FfiValue::Bool(true)), + ("i32".to_string(), FfiValue::Int32(-3)), + ("i64".to_string(), FfiValue::Int64(-4)), + ("u32".to_string(), FfiValue::UInt32(34)), + ("u64".to_string(), FfiValue::UInt64(u64::MAX)), + ("f32".to_string(), FfiValue::Float32(-0.0)), + ("f64".to_string(), FfiValue::Float64(f64::NAN)), + ("s".to_string(), s("alice")), + ( + "bytes".to_string(), + FfiValue::Bytes(Protected::new(vec![1, 2, 3])), + ), + ( + "list".to_string(), + FfiValue::Array(vec![s("a"), FfiValue::Passthrough(Box::new(s("p")))]), + ), + ("empty".to_string(), FfiValue::Array(vec![])), + ]) + } + + /// Reading an object refuses a key it has already read, wherever the + /// repeat falls. + #[test] + fn reading_a_repeated_key_is_refused() { + let keys: Vec = (0..1000).map(|i| format!("k{i}")).collect(); + let entries = |repeat: &str| { + let mut entries: Vec<(String, FfiValue)> = + keys.iter().map(|k| (k.clone(), FfiValue::Null)).collect(); + entries.push((repeat.to_string(), FfiValue::Null)); + FfiValue::Object(entries) + }; + for repeat in ["k0", "k500", "k999"] { + let refused = entries(repeat).visit(FfiValue::sink()); + assert!(refused.is_err(), "{repeat} read twice is refused"); + } + let read = entries("k1000") + .visit(FfiValue::sink()) + .expect("all distinct"); + assert!(matches!(read, FfiValue::Object(e) if e.len() == 1001)); + } + + #[test] + fn every_variant_survives_a_frame_round_trip() { + let frame = every_variant().to_frame().expect("frames"); + let back = FfiValue::from_frame(frame.risky_ref()).expect("reads back"); + assert_eq!(back, every_variant(), "the same value, variant for variant"); + } + + /// The width of an integer is part of the value: an `Int32` frames as a + /// 32-bit int and reads back as one, not as the 64-bit variant. + #[test] + fn integer_widths_are_kept_apart() { + for value in [ + FfiValue::Int32(7), + FfiValue::Int64(7), + FfiValue::UInt32(7), + FfiValue::UInt64(7), + ] { + let frame = value.to_frame().expect("frames"); + let back = FfiValue::from_frame(frame.risky_ref()).expect("reads back"); + assert_eq!(back.kind(), value.kind(), "{value:?} kept its width"); + } + } + + /// A narrower integer reads as the 32-bit variant of its sign, which + /// holds it exactly. + #[test] + fn a_narrower_integer_reads_as_the_32_bit_variant_of_its_sign() { + let read = |frame: Protected>| { + FfiValue::from_frame(frame.risky_ref()).expect("reads back") + }; + assert_eq!( + read(vitaminc_aead::to_frame(-8i8).expect("frames")), + FfiValue::Int32(-8) + ); + assert_eq!( + read(vitaminc_aead::to_frame(i16::MIN).expect("frames")), + FfiValue::Int32(i16::MIN.into()) + ); + assert_eq!( + read(vitaminc_aead::to_frame(u16::MAX).expect("frames")), + FfiValue::UInt32(u16::MAX.into()) + ); + // No Rust source walks a `u8` as an int (`Vec` is bytes), but a + // host's frame can carry one: the `UINT8` tag and its byte. + assert_eq!( + FfiValue::from_frame(&[vitaminc_aead::tags::UINT8, 200]).expect("reads back"), + FfiValue::UInt32(200) + ); + } + + /// A frame holding a scalar no variant holds is refused, not read as + /// the nearest variant. + #[test] + fn a_scalar_no_variant_holds_is_refused() { + for frame in [ + vitaminc_aead::to_frame(7u128).expect("frames"), + vitaminc_aead::to_frame(-7i128).expect("frames"), + ] { + assert!(matches!( + FfiValue::from_frame(frame.risky_ref()), + Err(FromFrameError::Value(DecodeError::Expected(_))) + )); + } + } + + #[test] + fn bytes_that_are_not_one_frame_are_refused() { + assert!(matches!( + FfiValue::from_frame(&[]), + Err(FromFrameError::Frame(_)) + )); + let mut frame = FfiValue::Null.to_frame().expect("frames").risky_unwrap(); + frame.push(0); + assert!(matches!( + FfiValue::from_frame(&frame), + Err(FromFrameError::Frame(_)) + )); + } + + #[test] + fn debug_redacts_text_and_bytes() { + assert_eq!(format!("{:?}", s("secret")), "String()"); + assert_eq!( + format!("{:?}", FfiValue::Bytes(Protected::new(vec![1]))), + "Bytes()" + ); + assert_eq!(format!("{:?}", FfiValue::UInt32(34)), "UInt32(34)"); + } + + /// Every variant's `Debug` form: numbers and booleans print, text and + /// bytes are redacted at any depth, so a container or a passthrough + /// holding a secret prints none of it. + #[test] + fn debug_prints_every_variant_and_redacts_at_any_depth() { + let cases = [ + (FfiValue::Null, "Null"), + (FfiValue::Undefined, "Undefined"), + (FfiValue::Bool(true), "Bool(true)"), + (FfiValue::Int32(-1), "Int32(-1)"), + (FfiValue::Int64(-2), "Int64(-2)"), + (FfiValue::UInt64(3), "UInt64(3)"), + (FfiValue::Float32(1.5), "Float32(1.5)"), + (FfiValue::Float64(2.5), "Float64(2.5)"), + ( + FfiValue::Array(vec![s("secret"), FfiValue::UInt32(1)]), + "Array([String(), UInt32(1)])", + ), + ( + FfiValue::Object(vec![("k".into(), s("secret"))]), + "Object([(\"k\", String())])", + ), + ( + FfiValue::Passthrough(Box::new(FfiValue::Bytes(Protected::new(vec![1])))), + "Passthrough(Bytes())", + ), + ]; + for (value, printed) in cases { + assert_eq!(format!("{value:?}"), printed); + } + } + + #[test] + fn kind_names_parse_back_exactly() { + for kind in ValueKind::ALL { + assert_eq!(kind.name().parse::(), Ok(kind)); + assert_eq!(kind.to_string(), kind.name()); + } + assert_eq!("UInt64".parse::(), Err(ParseValueKindError)); + assert_eq!("null".parse::(), Err(ParseValueKindError)); + assert!(ValueKind::UInt64.holds(&FfiValue::UInt64(34))); + assert!(!ValueKind::UInt64.holds(&FfiValue::Int64(34))); + assert_eq!(FfiValue::Passthrough(Box::new(s("p"))).kind(), None); + } +} diff --git a/packages/stack-encrypt/src/dynamic/kind.rs b/packages/stack-encrypt/src/dynamic/kind.rs index 1b49d3856..4d8ba4ade 100644 --- a/packages/stack-encrypt/src/dynamic/kind.rs +++ b/packages/stack-encrypt/src/dynamic/kind.rs @@ -6,13 +6,11 @@ //! 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 +//! The vocabulary is [`ValueKind`], whose //! [`name`](ValueKind::name)s (`"uint64"`, `"string"`, …, `"array"`, -//! `"object"`) are frozen wire format beside vitaminc's tag table, and whose +//! `"object"`) are frozen wire format, 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: +//! of a kind. Beside it, this module adds two rules: //! //! - [`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; @@ -37,7 +35,7 @@ //! field has none is refused when it is built //! ([`Error::UntypedIndex`], naming the field). A field that only seals, or //! only carries its value through, may leave it out: no term derives from it. -use vitaminc_aead_value::{FfiValue, ValueKind}; +use super::{FfiValue, ValueKind}; use super::{Error, Reason}; use crate::target::IndexSpec; diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index 8466f6bc9..1b4f8dd56 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -13,8 +13,8 @@ //! the typed operation. This module is that something, written once here //! rather than once per language binding. //! -//! The runtime value is [`FfiValue`], vitaminc's language-neutral value tree -//! and the type every binding already funnels through. +//! The runtime value is [`FfiValue`], a language-neutral value tree and +//! the type every binding already funnels through. //! //! # What is here //! @@ -70,6 +70,7 @@ //! tells a binding author that. [`Scope`] is exhaustive for a different //! reason, given on the type. mod context; +mod ffi_value; mod kind; pub mod record; mod target; @@ -79,20 +80,22 @@ mod value; use std::fmt; pub use context::{borrowed, context}; +/// The language-neutral value tree — the runtime value every binding +/// funnels through — and the value kinds, the type a plan field declares +/// in its `"type"` key. A kind's names are frozen wire format; see +/// [`admits`] and [`read`] for what a kind means to this engine. A value +/// crosses a binding's boundary as a transport frame +/// ([`FfiValue::to_frame`], [`FfiValue::from_frame`]); everything else here +/// takes and returns values, never encoded bytes. +pub use ffi_value::{ + Element, Entry, FfiValue, FromFrameError, ParseValueKindError, Put, Root, Utf8String, + ValueKind, ValueMap, ValueSeq, ValueSink, +}; pub use kind::{admits, read}; pub use record::{FieldPlan, Output, Plan}; pub use target::{NoTargets, TargetDescriptor, TargetError, TargetResolver}; pub use term::{term, Scalar, TermBytes}; -pub use value::Value; -/// 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; +pub use value::{AsValue, Value}; use crate::target::IndexSpec; use crate::{KeysetCipher, StackCipher}; @@ -137,7 +140,7 @@ impl fmt::Debug for Scope<'_, K> { /// The UTF-8 inside a string leaf. Valid by `Utf8String`'s construction /// invariant; checked rather than assumed because this is boundary code. -fn utf8(s: &vitaminc_aead_value::Utf8String) -> Option<&str> { +fn utf8(s: &Utf8String) -> Option<&str> { std::str::from_utf8(s.risky_ref()).ok() } @@ -526,6 +529,9 @@ reasons! { FieldType => "field_type", "a value is not of the type its field declares"; /// A passthrough sits where a sealed value, or a ciphertext, must be. Passthrough => "passthrough", "a passthrough where a sealed value must be"; + /// A passthrough value cannot be stored as a frame: it nests deeper, + /// or holds text, bytes or a key longer, than a frame carries. + Unframeable => "unframeable", "a passthrough value nests deeper, or holds a longer value, than a frame carries"; /// A stored field is not a map of outputs. OutputsNotMap => "outputs_not_map", "a stored field is not a map of outputs"; /// A stored sealed field has no `"c"` node. diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 64794dc87..84a64e75e 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -103,23 +103,13 @@ //! once checked — to the typed term operation, so they are the bytes a Rust //! `u32` or `String` field derives under the same label. //! -//! The ciphertext is where a data plan and a Rust chain over bare types part: -//! a Rust `u32` field seals four bare bytes and a `String` field its bare -//! UTF-8, a `Value` seals the tagged leaf, and the two encodings cannot be -//! told apart by inspection — a bare string that begins with U+000A is a -//! valid tagged string, and a tagged string is a valid bare one with a line -//! feed in front — so neither reader can refuse the other's leaf and a -//! mis-declared one may read as changed plaintext rather than fail. The -//! lowering therefore never chooses an encoding from the type. A Rust record -//! whose rows a binding must open declares its fields as [`Value`] -//! (`encrypt_index(pick("age", |u: &User| &u.age), (IndexSpec::Equality, -//! IndexSpec::Ore))` over a `Value` field is the same declaration as the -//! data plan's, and the two interchange, leaves included — see -//! `tests/record_lowering.rs`); a Rust `u32` field shares a data field's -//! terms and nothing else. Closing that gap — one leaf encoding both authors -//! read, or the encoding bound into the leaf's context so the wrong reader -//! fails closed — is a change to the Rust chain's bytes, and so a decision -//! tracked in #1118, not something the lowering can take on its own. +//! The ciphertext is where a data plan and a Rust chain meet too. Since +//! vitaminc ADR 0001 every leaf is tagged, a typed Rust value's as much as +//! a [`Value`]'s, so a Rust `u32` field and a `uint32` data field seal the +//! same leaf under the same context, and each opens the other's. A reader +//! of another type refuses a leaf rather than reading it as changed +//! plaintext: the tag is authenticated. The lowering still never chooses an +//! encoding from the type; it no longer has one to choose. //! //! # The stored record is wire format //! @@ -147,7 +137,8 @@ //! reported as a successful decrypt. The encrypt-side refusal is what makes //! that a round-trip invariant rather than data loss. -use vitaminc_aead_value::{FfiValue, ValueKind}; +use super::{FfiValue, ValueKind}; +use vitaminc_aead::frame::FrameWriteError; use vitaminc_protected::Controlled; use super::{ @@ -157,7 +148,7 @@ use super::{ use crate::plan::{FieldValues, FieldsBuilder, Opens, Runs}; use crate::target::{CallerContext, DeclaredContext, Decryption, Encrypted, IndexSpec}; use crate::{ - BoxedPassthrough, CipherText, ContextPiece, KeysetCipher, Label, NonEmpty, Pending, + CipherText, ContextPiece, KeysetCipher, Label, NonEmpty, PassthroughFrame, Pending, StackCipherText, }; @@ -1661,7 +1652,6 @@ impl RecordTree for StackCipherText { CipherText::Map(entries) => Children::Map(entries), CipherText::Passthrough(_) | CipherText::Single(_) - | CipherText::None(_) | CipherText::EmptySequence(_) | CipherText::EmptyMap(_) => Children::None, } @@ -1843,12 +1833,26 @@ fn check_field(value: &FfiValue, field: &FieldPlan) -> Result<(), Error> { }); } } - Output::Passthrough => {} + Output::Passthrough => check_frame(value, &field.name)?, } } Ok(()) } +/// A passthrough value is stored as its frame ([`passthrough`]), so one the +/// frame cannot carry is refused here, naming its field, before any key is +/// requested: a repeated key as [`Reason::RepeatedKey`], anything else (too +/// deep, too long) as [`Reason::Unframeable`]. Left to the output adapter it +/// failed the whole record as a bare [`Aead`](crate::Error::Aead), after the +/// key round trip. The trial frame is wiped as it drops. +fn check_frame(value: &FfiValue, field: &str) -> Result<(), Error> { + match value.to_frame() { + Ok(_) => Ok(()), + Err(FrameWriteError::DuplicateKey) => Err(FfiValue::misfit(field, Reason::RepeatedKey)), + Err(_) => Err(FfiValue::misfit(field, Reason::Unframeable)), + } +} + // ============================================================================= // Output adapters: the engine's record as the stored shape, and back // ============================================================================= @@ -1872,10 +1876,27 @@ fn slot(values: &mut FieldValues, name: &str) -> Result StackCipherText { - CipherText::Passthrough(Box::new(FfiValue::Bytes(vitaminc_protected::Protected::new( +fn term_node(term: TermBytes) -> Result { + passthrough(&FfiValue::Bytes(vitaminc_protected::Protected::new( term.into_bytes(), - ))) as BoxedPassthrough) + ))) +} + +/// A value as a passthrough node: the value's frame, in the clear. A source +/// value the frame cannot carry is refused before this runs +/// ([`check_frame`]); one that reaches it anyway is refused as a value +/// vitaminc refuses to encrypt is, with [`Aead`](crate::Error::Aead). +fn passthrough(value: &FfiValue) -> Result { + let frame = value.to_frame().map_err(|_| crate::Error::Aead)?; + Ok(CipherText::Passthrough(PassthroughFrame( + frame.risky_unwrap(), + ))) +} + +/// The one value a passthrough node carries, or `None` if its frame holds +/// something no [`FfiValue`] holds. +fn passthrough_value(payload: &PassthroughFrame) -> Option { + FfiValue::from_frame(&payload.0).ok() } /// The record the plan produced, in the stored shape: per field, in plan @@ -1896,9 +1917,7 @@ fn shape_record( let value = eql.next().ok_or(crate::Error::ResponseShape)?; vec![( EQL_KEY.to_string(), - CipherText::Passthrough(Box::new(FfiValue::Bytes( - vitaminc_protected::Protected::new(value), - )) as BoxedPassthrough), + passthrough(&FfiValue::Bytes(vitaminc_protected::Protected::new(value)))?, )] } Verb::Encrypt => { @@ -1915,10 +1934,7 @@ fn shape_record( Verb::Index => keyed_terms(slot(&mut values, &field.name)?, &field.keys)?, Verb::Passthrough => { let value = take_leaf(&mut values, &field.name)?; - vec![( - "passthrough".to_string(), - CipherText::Passthrough(Box::new(value) as BoxedPassthrough), - )] + vec![("passthrough".to_string(), passthrough(&value)?)] } }; fields.push((field.name.clone(), CipherText::Map(outputs))); @@ -1936,11 +1952,10 @@ fn keyed_terms( if terms.len() != keys.len() { return Err(crate::Error::ResponseShape); } - Ok(keys - .iter() + keys.iter() .zip(terms) - .map(|(key, term)| ((*key).to_string(), term_node(term))) - .collect()) + .map(|(key, term)| Ok(((*key).to_string(), term_node(term)?))) + .collect() } /// The field `name` out of the record, as the value it holds. @@ -2035,9 +2050,8 @@ fn record_row(mut row: Vec<(String, StackCipherText)>, plan: &Plan) -> Result() - .map_err(|_| misfit(Reason::NotPassthrough))?; + let value = + passthrough_value(&payload).ok_or_else(|| misfit(Reason::NotPassthrough))?; let FfiValue::Bytes(bytes) = value else { return Err(misfit(Reason::NotPassthrough)); }; @@ -2049,9 +2063,8 @@ fn record_row(mut row: Vec<(String, StackCipherText)>, plan: &Plan) -> Result() - .map_err(|_| misfit(Reason::NotPassthrough))?; + let value = + passthrough_value(&payload).ok_or_else(|| misfit(Reason::NotPassthrough))?; if field.field_type.is_some_and(|kind| !kind.holds(&value)) { return Err(misfit(Reason::FieldType)); } @@ -2078,6 +2091,7 @@ mod tests { IdentifiedBy, IndexKey, IndexKeySource, RetrieveKeyPayload, UnverifiedContext, }; use uuid::Uuid; + use vitaminc_aead::frame::MAX_DEPTH; use vitaminc_protected::{Controlled, Protected}; use crate::dynamic::{context, term}; @@ -2149,6 +2163,11 @@ mod tests { // ---- values, as a binding would decode them ---------------------------- + /// A null inside `depth` nested arrays. + fn nested(depth: usize) -> FfiValue { + (0..depth).fold(FfiValue::Null, |inner, _| FfiValue::Array(vec![inner])) + } + fn s(value: &str) -> FfiValue { FfiValue::String(value.into()) } @@ -2274,7 +2293,7 @@ mod tests { fn term_bytes(node: &StackCipherText) -> Vec { match node { - CipherText::Passthrough(payload) => match (**payload).downcast_ref::() { + CipherText::Passthrough(payload) => match passthrough_value(payload) { Some(FfiValue::Bytes(bytes)) => bytes.risky_ref().to_vec(), _ => panic!("a term rides as a passthrough byte node"), }, @@ -2304,7 +2323,7 @@ mod tests { } fn forged(value: FfiValue) -> StackCipherText { - CipherText::Passthrough(Box::new(value) as BoxedPassthrough) + passthrough(&value).unwrap() } fn plan_error(error: crate::Error) -> PlanError { @@ -3199,7 +3218,7 @@ mod tests { panic!("a passthrough field rides as a passthrough node"); }; assert_eq!( - u64_of(payload.downcast_ref::().expect("a value")), + u64_of(&passthrough_value(&payload).expect("a value")), 7, "carrying the value as it is" ); @@ -4256,7 +4275,7 @@ mod tests { let CipherText::Passthrough(payload) = node(&mut tenant, "passthrough") else { panic!("the context field rides as a passthrough"); }; - let FfiValue::String(stored) = *payload.downcast::().expect("a value") else { + let FfiValue::String(stored) = passthrough_value(&payload).expect("a value") else { panic!("the stored context is text"); }; assert_eq!(utf8(&stored), Some("tenants/acme")); @@ -4811,7 +4830,7 @@ mod tests { .expect("the shape fits") .await; assert_eq!( - plan_error(result.err().expect("refused")), + plan_error(result.expect_err("refused")), PlanError::FieldType { field: "age".into(), expected: "int64", @@ -4843,7 +4862,7 @@ mod tests { .expect("the shape fits") .await; assert_eq!( - plan_error(result.err().expect("the batch is refused")), + plan_error(result.expect_err("the batch is refused")), PlanError::FieldType { field: "age".into(), expected: "uint64", @@ -5321,7 +5340,7 @@ mod tests { let CipherText::Passthrough(payload) = node(&mut outputs, EQL_KEY) else { panic!("the eql node is a passthrough"); }; - let FfiValue::Bytes(bytes) = *payload.downcast::().unwrap() else { + let FfiValue::Bytes(bytes) = passthrough_value(&payload).unwrap() else { panic!("the eql node carries bytes"); }; serde_json::from_slice(bytes.risky_ref()).expect("the eql node is JSON") @@ -5916,9 +5935,7 @@ mod tests { let bytes_node = |bytes: &[u8]| { CipherText::Map(vec![( EQL_KEY.to_string(), - CipherText::Passthrough(Box::new(FfiValue::Bytes(Protected::new( - bytes.to_vec(), - ))) as BoxedPassthrough), + passthrough(&FfiValue::Bytes(Protected::new(bytes.to_vec()))).unwrap(), )]) }; // Each misfit is refused naming the field and the reason a @@ -5954,7 +5971,7 @@ mod tests { // A payload that is not bytes. let null = CipherText::Map(vec![( EQL_KEY.to_string(), - CipherText::Passthrough(Box::new(FfiValue::Null) as BoxedPassthrough), + passthrough(&FfiValue::Null).unwrap(), )]); let record = with_email(seal_mixed(&keyset, &plan).await, null); assert_eq!( @@ -6053,9 +6070,7 @@ mod tests { "email".to_string(), CipherText::Map(vec![( EQL_KEY.to_string(), - CipherText::Passthrough(Box::new(FfiValue::Bytes(Protected::new( - b"{}".to_vec(), - ))) as BoxedPassthrough), + passthrough(&FfiValue::Bytes(Protected::new(b"{}".to_vec()))).unwrap(), )]), )]); let error = target_error(refused(decrypt_with( @@ -6074,11 +6089,10 @@ mod tests { } /// The leaf encoding, pinned: every field lowered from data seals the - /// tagged `Value` leaf whatever its `"type"`, so a type declared later - /// changes no bytes; a Rust field of a bare type shares a data field's - /// terms and not its leaf, and the two leaves cannot be told apart by - /// inspection, which is why the lowering never picks an encoding from - /// the type. + /// tagged leaf its value seals as, whatever its `"type"`, so a type + /// declared later changes no bytes; and a Rust field of the same type + /// seals the same leaf, so a derived record and a data plan share + /// terms and leaves. mod given_the_leaf_encoding { use super::*; @@ -6090,8 +6104,8 @@ mod tests { } /// A `uint32` field and a `string` field seal the tagged leaf, as an - /// untyped field does: the leaf opens as a value, and a bare `u32` - /// reader refuses the five bytes it finds. + /// untyped field does: the leaf opens as a value, and as the bare + /// Rust type of the same kind. #[tokio::test] async fn every_field_seals_the_tagged_leaf_whatever_its_type() { let cipher = cipher().await; @@ -6116,9 +6130,15 @@ mod tests { let mut age = map(node(&mut fields, "age")); let as_u32: Result = cipher.decrypt(node(&mut age, "c"), age_label.clone()).await; + assert_eq!(as_u32.ok(), Some(34), "a uint32 leaf is a u32, type {ty:?}"); + let mut fields = + map(seal(&keyset, obj(vec![("age", FfiValue::UInt32(34))]), &plan).await); + let mut age = map(node(&mut fields, "age")); + let as_string: Result = + cipher.decrypt(node(&mut age, "c"), age_label.clone()).await; assert!( - matches!(as_u32, Err(crate::Error::Aead)), - "five tagged bytes are not a bare u32, type {ty:?}: {as_u32:?}" + matches!(as_string, Err(crate::Error::Aead)), + "a uint32 leaf is not text, type {ty:?}: {as_string:?}" ); } let plan = plan(obj(vec![( @@ -6181,14 +6201,10 @@ mod tests { } /// A derived `plaintext = u32` record and a `uint32` data-plan field - /// under one label derive the same term. Their leaves are different - /// encodings, and neither reader opens the other's: the derive's bare - /// four bytes carry no tag for the lowering, the lowering's five - /// tagged bytes are not a `u32`. A Rust record that must interchange - /// leaves with a data plan declares `Value` fields instead - /// (`seals_what_the_chain_seals_under_the_same_declaration`). + /// under one label derive the same term and seal the same leaf, so + /// each reader opens the other's. #[tokio::test] - async fn the_derive_and_a_data_plan_share_terms_not_leaves() { + async fn the_derive_and_a_data_plan_share_terms_and_leaves() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let label_ = Label::parse("users/age").expect("label"); @@ -6211,37 +6227,29 @@ mod tests { "the same equality term" ); - let as_u32: Result = keyset + let as_u32: u32 = keyset .decrypt_as( node(&mut age, "c"), AeadContext::from(NonEmpty::from(label_.clone())), ) - .await; - assert!( - matches!(as_u32, Err(crate::Error::Aead)), - "the derive does not open the lowering's leaf: {as_u32:?}" - ); + .await + .expect("the derive opens the lowering's leaf"); + assert_eq!(as_u32, 34); let stored = CipherText::Map(vec![( "age".to_string(), CipherText::Map(vec![("c".to_string(), derived.c)]), )]); - let result = decrypt(Scope::Client(&cipher), stored, &plan, None) + let opened = decrypt(Scope::Client(&cipher), stored, &plan, None) .expect("the shape fits") - .await; - assert!( - matches!(result, Err(crate::Error::Aead)), - "the lowering does not open the derive's leaf: {:?}", - result.err() - ); + .await + .expect("the lowering opens the derive's leaf"); + assert_eq!(u32_of(&object(opened)[0].1), 34); } - /// The two encodings cannot be told apart by inspection: a bare - /// `String` reader accepts a tagged string leaf, reading the tag as - /// a line feed. Pinned so that it is known, never relied on: the - /// lowering reads only its own encoding, and a Rust record that - /// shares rows with a binding declares `Value` fields. + /// A bare `String` reader opens a data plan's string leaf to the + /// text it holds: the tag is the leaf's type, not part of the text. #[tokio::test] - async fn a_bare_string_reader_cannot_tell_a_tagged_leaf_apart() { + async fn a_bare_string_reader_reads_a_data_plan_string() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let plan = plan(obj(vec![("email", spec(label("email"), &["c"]))])).expect("plan"); @@ -6253,8 +6261,8 @@ mod tests { Label::parse("users/email").expect("label"), ) .await - .expect("valid UTF-8 either way"); - assert_eq!(bare, "\nalice", "the string tag, U+000A, read as text"); + .expect("a string leaf"); + assert_eq!(bare, "alice"); } } @@ -6515,6 +6523,27 @@ mod tests { } } + /// A passthrough the frame cannot carry is refused when the source is + /// read, naming its field, before any key is generated. + #[tokio::test] + async fn an_unframeable_passthrough_is_refused_before_keys_are_generated() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let mut source = object(row(34)); + let _ = take(&mut source, "id"); + source.push(("id".to_string(), nested(MAX_DEPTH + 1))); + let refused = encrypt(&keyset, FfiValue::Object(source), &the_plan()) + .err() + .expect("refused before the pending exists"); + expect(&refused, "Source", Some("id"), Reason::Unframeable); + assert_eq!(generates(&cipher), 0, "no key was generated"); + + let mut source = object(row(34)); + let _ = take(&mut source, "id"); + source.push(("id".to_string(), nested(MAX_DEPTH - 1))); + let _ = seal(&keyset, FfiValue::Object(source), &the_plan()).await; + } + #[test] fn a_source_that_does_not_fit() { use Reason::*; @@ -6535,8 +6564,30 @@ mod tests { "email".to_string(), FfiValue::Passthrough(Box::new(s("a@x"))), )); + let mut deep_id = without("id"); + deep_id.push(("id".to_string(), nested(MAX_DEPTH + 1))); + let mut repeated_id = without("id"); + repeated_id.push(( + "id".to_string(), + FfiValue::Object(vec![ + ("k".to_string(), FfiValue::Null), + ("k".to_string(), FfiValue::Null), + ]), + )); let cases: Vec<(&str, FfiValue, Option<&str>, Reason)> = vec![ ("not an object", s("x"), None, NotAnObject), + ( + "a passthrough deeper than a frame carries", + FfiValue::Object(deep_id), + Some("id"), + Unframeable, + ), + ( + "a passthrough with a repeated key", + FfiValue::Object(repeated_id), + Some("id"), + RepeatedKey, + ), ( "a batch row that is not an object", FfiValue::Array(vec![s("x")]), diff --git a/packages/stack-encrypt/src/dynamic/target.rs b/packages/stack-encrypt/src/dynamic/target.rs index d533edc43..fe46d87ba 100644 --- a/packages/stack-encrypt/src/dynamic/target.rs +++ b/packages/stack-encrypt/src/dynamic/target.rs @@ -27,7 +27,7 @@ use std::fmt; -use vitaminc_aead_value::{FfiValue, ValueKind}; +use super::{FfiValue, ValueKind}; use crate::{KeysetCipher, Label, Pending, StackCipher}; diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index 9d5670e18..de0b79852 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -14,8 +14,8 @@ //! going to be **stored** should come from the record path, where it shares //! one context with the ciphertext beside it (ADR-0004). +use super::FfiValue; use stack_kms::DataKeySource; -use vitaminc_aead_value::FfiValue; use vitaminc_protected::{Controlled, OpaqueDebug, Protected}; use zeroize::Zeroizing; diff --git a/packages/stack-encrypt/src/dynamic/value.rs b/packages/stack-encrypt/src/dynamic/value.rs index a0b54a5c0..4d92c52dc 100644 --- a/packages/stack-encrypt/src/dynamic/value.rs +++ b/packages/stack-encrypt/src/dynamic/value.rs @@ -2,10 +2,12 @@ use std::fmt; -use vitaminc_aead::{Cipher, Decipher, Decrypt, Encrypt, IntoAad}; -use vitaminc_aead_value::FfiValue; +use vitaminc_aead::plaintext::Sink; +use vitaminc_aead::{Decrypt, Encrypt}; use vitaminc_protected::{Controlled, Protected}; +use super::{FfiValue, Put, ValueSink}; + use crate::plan::IntoLabel; use crate::{Label, LabelError}; @@ -24,11 +26,9 @@ use crate::{Label, LabelError}; /// each once per operation that consumes it, as the derive's does. /// /// It seals and opens as the [`FfiValue`] it wraps, in vitaminc's -/// self-describing tagged leaf encoding — a different leaf from a bare -/// `u32`'s or `String`'s, and one the other reader cannot tell apart by -/// inspection; [`dynamic::record`](super::record) says what follows from -/// that. Its `Debug` names the type and nothing else: the value is -/// plaintext. +/// self-describing tagged leaf encoding — the same leaf a bare `u32` or +/// `String` of the same value seals as. Its `Debug` names the type and +/// nothing else: the value is plaintext. pub struct Value(FfiValue); impl Value { @@ -106,22 +106,32 @@ fn duplicate(value: &FfiValue) -> FfiValue { } impl Encrypt for Value { - fn encrypt_with_aad<'a, C, A>(self, cipher: C, aad: A) -> Result - where - C: Cipher, - A: IntoAad<'a>, - { - self.0.encrypt_with_aad(cipher, aad) + fn visit(self, sink: S) -> Result { + self.0.visit(sink) + } +} + +impl Encrypt for &Value { + fn visit(self, sink: S) -> Result { + (&self.0).visit(sink) } } -impl<'c> Decrypt<'c> for Value { - fn decrypt_with_aad<'a, D, A>(decipher: D, aad: A) -> D::Ok - where - D: Decipher<'c>, - A: IntoAad<'a>, - { - D::map_ok(FfiValue::decrypt_with_aad(decipher, aad), Value) +impl Decrypt for Value { + type Sink = ValueSink; + fn sink() -> Self::Sink { + ValueSink::new(AsValue) + } +} + +/// Where [`Value`]'s [`Decrypt`] sink puts what it builds: the +/// [`FfiValue`] sink's value, wrapped. +pub struct AsValue; + +impl Put for AsValue { + type Ok = Value; + fn put(self, value: FfiValue) -> Value { + Value(value) } } diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 65059a6ae..8afe073e5 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -26,9 +26,10 @@ //! the cipher bound to one of that client's keysets, from //! [`default_keyset`](StackCipher::default_keyset) or //! [`keyset`](StackCipher::keyset) — encrypts any value that implements -//! [`Encrypt`] (`String`, `Vec`, `HashMap`, `Option`, -//! `Protected`, your own types, and any nesting of them). Either cipher -//! decrypts back into any [`Decrypt`] type. Every scalar inside the value is +//! vitaminc's [`Encrypt`] (`String`, `Vec`, `HashMap`, +//! `Option`, `Protected`, your own types through +//! `#[derive(vitaminc_aead::Encrypt)]`, and any nesting of them). Either +//! cipher decrypts back into any [`Decrypt`] type. Every scalar inside the value is //! sealed under its **own** ZeroKMS data key, so each value access is an //! individually auditable key retrieval — there is no long-lived key in your //! process. @@ -225,8 +226,9 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! //! Besides your AAD, the *shape* of a value is authenticated: an element cannot //! be spliced out of a sequence and passed off as a scalar, a map value cannot -//! be moved under a different key, and "absent" / "empty" are themselves -//! sealed markers rather than inferable from structure. A tampered or +//! be moved under a different key, "empty" is itself a sealed marker rather +//! than inferable from structure, and "absent" is a sealed null leaf. Every +//! leaf records its own type, so a leaf cannot be read back as another. A tampered or //! re-homed ciphertext fails with [`Error::Aead`]; a failed or denied key //! retrieval surfaces as [`Error::Kms`]. A ciphertext opened under the //! *wrong context* is refused by ZeroKMS first: every data key is bound to @@ -236,18 +238,14 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! source that ignores descriptors (`FakeDataKeySource`, in tests) lets a //! wrong context reach the AEAD, where it is [`Error::Aead`]. //! -//! For one-row reads of a batch-encrypted collection, decrypt as -//! [`Element`](Element) under the same AAD used for the whole collection: -//! the derivation that binds an element to its position is applied by the -//! type, not by the caller. That is the general rule here — every leaf's AAD -//! is derived from the context its key was minted under, and there is no -//! entry point that lets a caller supply one of its own. +//! Every leaf's AAD is derived from the context its key was minted under, +//! and there is no entry point that lets a caller supply one of its own. //! //! # Relationship to vitaminc //! -//! A `KeysetCipher` is a vitaminc [`Cipher`]; everything a vitaminc cipher can -//! encrypt, it can encrypt, and the AEAD, AAD derivations and leaf wire format -//! are vitaminc's (`vitaminc_encrypt::Aes256Cipher`, AES-256-GCM under a random +//! A `KeysetCipher` seals through a vitaminc `Cipher`; everything vitaminc +//! can encrypt, it can encrypt, and the data model, the AAD derivations and +//! the leaf wire format are vitaminc's (`vitaminc_encrypt::Aes256Cipher`, AES-256-GCM under a random //! per-leaf nonce vitaminc generates itself). The ZeroKMS `iv` a [`SealedValue`] //! carries is *not* that nonce: it identifies the data key, and is sent back to //! ZeroKMS with the key `tag` to re-derive it — under the same [`Descriptor`] @@ -283,8 +281,8 @@ pub mod sem; pub mod target; pub use cipher::{ - BoxedPassthrough, Error, FromEnv, LeafBytesError, PendingStackCipherText, SealedValue, - StackCipher, StackCipherBuilder, StackCipherText, StackDecipher, + Error, FromEnv, LeafBytesError, PendingStackCipherText, SealedValue, StackCipher, + StackCipherBuilder, StackCipherText, }; pub use descriptor::{Describe, Description, Descriptor, Label, LabelError}; pub use keyset::KeysetCipher; @@ -307,13 +305,14 @@ pub use target::{ }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they -// don't have to depend on `vitaminc-aead` directly for the common path. A -// context type of your own implements `IntoContext` once; `IntoAad` and -// `IntoPrfContext` are vitaminc's blankets over it, so both derivations see -// the same bytes. +// don't have to depend on `vitaminc-aead` directly for the common path: a +// value describes itself once (`Encrypt`) and names the sink that reads it +// back (`Decrypt`). A context type of your own implements `IntoContext` once; +// `IntoAad` and `IntoPrfContext` are vitaminc's blankets over it, so both +// derivations see the same bytes. pub use vitaminc_aead::{ - Cipher, CipherText, Context, ContextPiece, ContextTag, Decipher, Decrypt, Element, Encrypt, - IntoAad, IntoContext, Unspecified, + CipherText, Context, ContextPiece, ContextTag, Decrypt, Encrypt, IntoAad, IntoContext, Node, + PassthroughFrame, Unspecified, }; // The vitaminc 0.4 names, deprecated there; re-exported for one transition so // a caller that spelled them keeps compiling with a warning. diff --git a/packages/stack-encrypt/src/plan/build.rs b/packages/stack-encrypt/src/plan/build.rs index 5ecc20d1e..3cbf85ca1 100644 --- a/packages/stack-encrypt/src/plan/build.rs +++ b/packages/stack-encrypt/src/plan/build.rs @@ -603,7 +603,7 @@ fn stored(field: &str, slot: Slot) -> Result { /// Open a sealed field's ciphertext as an `F`. fn open_sealed(ciphertext: StackCipherText, context: CallerContext) -> Decryption where - F: crate::Decrypt<'static> + Send + 'static, + F: crate::Decrypt + Send + 'static, K: 'static, { open::(ciphertext, AeadContext::from(context)).map(Slot::new) @@ -727,7 +727,7 @@ impl FieldsBuilder { /// `/`. Lowers to [`ciphertext`]`().under(label)`. pub fn encrypt(self, field: impl FieldRef) -> Self where - F: crate::Encrypt + crate::Decrypt<'static> + Clone + Send + 'static, + F: crate::Encrypt + crate::Decrypt + Clone + Send + 'static, { let (name, access) = resolve(field); let read = Arc::clone(&access.read); @@ -764,14 +764,14 @@ impl FieldsBuilder { indexes: impl Indexes + Clone + Send + Sync + 'static, ) -> Self where - F: crate::Encrypt + crate::Decrypt<'static> + Clone + Send + 'static, + F: crate::Encrypt + crate::Decrypt + Clone + Send + 'static, { self.encrypt_index_of(field, indexes) } fn encrypt_index_of(self, field: impl FieldRef, indexes: X) -> Self where - F: crate::Encrypt + crate::Decrypt<'static> + Clone + Send + 'static, + F: crate::Encrypt + crate::Decrypt + Clone + Send + 'static, X: Indexes + Clone + Send + Sync + 'static, X::Terms: Send, { diff --git a/packages/stack-encrypt/src/plan/chain.rs b/packages/stack-encrypt/src/plan/chain.rs index 5e34bc999..a6842d175 100644 --- a/packages/stack-encrypt/src/plan/chain.rs +++ b/packages/stack-encrypt/src/plan/chain.rs @@ -483,7 +483,7 @@ impl<'a, S: 'static, K: 'static> EncryptFields<'a, S, K> { /// [`FieldsBuilder::encrypt`]. pub fn encrypt(mut self, field: impl FieldRef) -> Self where - F: crate::Encrypt + crate::Decrypt<'static> + Clone + Send + 'static, + F: crate::Encrypt + crate::Decrypt + Clone + Send + 'static, { self.plan = self.plan.encrypt::(field); self @@ -495,7 +495,7 @@ impl<'a, S: 'static, K: 'static> EncryptFields<'a, S, K> { indexes: impl Indexes + Clone + Send + Sync + 'static, ) -> Self where - F: crate::Encrypt + crate::Decrypt<'static> + Clone + Send + 'static, + F: crate::Encrypt + crate::Decrypt + Clone + Send + 'static, { self.plan = self.plan.encrypt_index::(field, indexes); self diff --git a/packages/stack-encrypt/src/plan/mod.rs b/packages/stack-encrypt/src/plan/mod.rs index 623d3884d..b91f6a0a5 100644 --- a/packages/stack-encrypt/src/plan/mod.rs +++ b/packages/stack-encrypt/src/plan/mod.rs @@ -273,15 +273,10 @@ //! sources only the call has no data form: a binding names the context in //! the declaration it sends, or in the record. //! -//! The engine is shared; the stored leaf is not. A data plan seals every -//! field as the tagged `dynamic::Value` leaf, whatever its type, so a Rust -//! record whose rows a binding also reads must declare those fields as -//! `dynamic::Value`. A bare `String` or `u32` field derives the same terms -//! but encrypts a different leaf. A bare `String` reader accepts a -//! binding's leaf and returns its type tag as a leading line feed -//! (`"\nalice"`), with no error. One leaf encoding for both, or the -//! encoding bound into the leaf's context so the wrong reader fails, is -//! tracked in #1118. +//! The engine is shared, and so is the stored leaf: every leaf is tagged +//! with its type (vitaminc ADR 0001), so a data plan's `uint32` field and a +//! Rust `u32` field seal the same leaf under the same context, and a reader +//! of another type refuses it rather than misreading it. //! //! # How a chain lowers //! diff --git a/packages/stack-encrypt/src/plan/value.rs b/packages/stack-encrypt/src/plan/value.rs index 9ff54b49f..13b0f63fc 100644 --- a/packages/stack-encrypt/src/plan/value.rs +++ b/packages/stack-encrypt/src/plan/value.rs @@ -660,7 +660,7 @@ macro_rules! indexed_opens { ($([$($generics:tt)*] $record:ty;)+) => {$( impl Opens<$record, K> for ValuePlan> where - S: crate::Decrypt<'static> + 'static, + S: crate::Decrypt + Send + 'static, K: 'static, { type Output = S; @@ -678,7 +678,7 @@ macro_rules! indexed_opens { } impl Opens, K> for ValuePlan> where - S: crate::Decrypt<'static> + 'static, + S: crate::Decrypt + Send + 'static, K: 'static, { type Output = Vec; diff --git a/packages/stack-encrypt/src/target/core.rs b/packages/stack-encrypt/src/target/core.rs index 7b76831bd..c06a84f03 100644 --- a/packages/stack-encrypt/src/target/core.rs +++ b/packages/stack-encrypt/src/target/core.rs @@ -1,6 +1,6 @@ //! Canonical execution shared by the cipher-directed and declaration APIs. use super::{CipherScope, Pending, Request, Responses}; -use crate::cipher::{bind_keys, PendingStackCipherText, StackDecipher}; +use crate::cipher::{bind_keys, open_keyed, KeyedCipherText, PendingStackCipherText}; use crate::{Descriptor, Error, KeysetCipher, StackCipher, StackCipherText}; use vitaminc_aead::{CipherText, Decrypt, Encrypt, IntoAad, IntoContext}; use vitaminc_protected::NonEmpty; @@ -21,9 +21,11 @@ pub(crate) trait Term: Sized { } /// Seal `source` into the native tree. It is consumed, as Vitamin C's -/// `Encrypt` consumes it: a caller holding only a borrow clones before it -/// gets here (see [`ConsumeSource`](super::ConsumeSource)), and one holding -/// the value hands it over without a copy. +/// by-value [`Encrypt`] walk consumes it: a caller holding only a borrow +/// clones before it gets here (see [`ConsumeSource`](super::ConsumeSource)), +/// and one holding the value hands it over without a copy. The walk happens +/// here, before any request exists, so the pending holds only the planned +/// leaves. pub(crate) fn encrypt_native<'a, 'c, S: Encrypt, K, T: IntoContext<'c>>( source: S, cipher: &'a KeysetCipher<'_, K>, @@ -35,12 +37,12 @@ pub(crate) fn encrypt_native<'a, 'c, S: Encrypt, K, T: IntoContext<'c>>( return Pending::failed(cipher, error); } let aad = context.into_aad().into_owned(); - match source.encrypt_with_aad(cipher, aad) { + match PendingStackCipherText::plan(source, aad) { Ok(tree) => seal_pending(cipher, tree, descriptor), - Err(_) => Pending::ready(cipher, Err(Error::Aead)), + Err(error) => Pending::ready(cipher, Err(error)), } } -pub(crate) fn open_native<'a, 'c, P: Decrypt<'static> + 'static, K, T: IntoContext<'c>>( +pub(crate) fn open_native<'a, 'c, P: Decrypt + Send + 'static, K, T: IntoContext<'c>>( tree: StackCipherText, cipher: &'a StackCipher, context: NonEmpty, @@ -54,8 +56,8 @@ pub(crate) fn open_native<'a, 'c, P: Decrypt<'static> + 'static, K, T: IntoConte let requests = retrieve_requests(&tree, &descriptor); let aad = context.into_aad().into_owned(); Pending::request(cipher, requests, move |responses| { - let decipher = decipher_from_responses(tree, responses)?; - P::decrypt_with_aad(decipher, aad).map_err(Error::from) + let keyed = keyed_from_responses(tree, responses)?; + open_keyed(keyed, aad) }) } pub(crate) fn seal_pending<'a, K>( @@ -74,30 +76,32 @@ pub(crate) fn seal_pending<'a, K>( .take(tree.key_count()) .collect(); Pending::request(cipher, requests, move |responses| { - let mut keys = responses.drain_generated(); - tree.seal_with(keyset_id, &mut keys).map_err(Error::from) + tree.seal_with(keyset_id, responses.drain_generated()) + .map_err(Error::from) }) } -pub(crate) fn decipher_pending<'a, K>( +/// Retrieve every leaf's data key and bind it onto its leaf, ready for +/// [`open_keyed`](crate::cipher::open_keyed). +pub(crate) fn bind_pending<'a, K>( scope: impl CipherScope<'a, K>, ciphertext: StackCipherText, descriptor: Descriptor, -) -> Pending<'a, StackDecipher, K> { +) -> Pending<'a, KeyedCipherText, K> { // Fast path, as in `seal_pending`; `dispatch` is the gate. if let Err(e) = descriptor.check() { return Pending::ready(scope, Err(e)); } let requests = retrieve_requests(&ciphertext, &descriptor); Pending::request(scope, requests, move |responses| { - decipher_from_responses(ciphertext, responses) + keyed_from_responses(ciphertext, responses) }) } -fn decipher_from_responses( +fn keyed_from_responses( ciphertext: StackCipherText, responses: &mut Responses, -) -> Result { +) -> Result { let mut keys = responses.drain_retrieved(); // Too few keys for the tree, or keys left over once it is bound, both // mean `retrieve_requests` and `bind_keys` disagreed about the tree's @@ -107,7 +111,7 @@ fn decipher_from_responses( if keys.next().is_some() { return Err(Error::ResponseShape); } - Ok(StackDecipher::over(keyed)) + Ok(keyed) } fn retrieve_requests(ciphertext: &StackCipherText, descriptor: &Descriptor) -> Vec { @@ -122,10 +126,7 @@ fn collect_retrieve_requests( out: &mut Vec, ) { match ciphertext { - CipherText::Single(leaf) - | CipherText::None(leaf) - | CipherText::EmptySequence(leaf) - | CipherText::EmptyMap(leaf) => { + CipherText::Single(leaf) | CipherText::EmptySequence(leaf) | CipherText::EmptyMap(leaf) => { out.push(Request::retrieve_under( *leaf.iv(), leaf.tag().to_vec(), diff --git a/packages/stack-encrypt/src/target/index.rs b/packages/stack-encrypt/src/target/index.rs index 7006f16a8..d44d950c0 100644 --- a/packages/stack-encrypt/src/target/index.rs +++ b/packages/stack-encrypt/src/target/index.rs @@ -493,7 +493,7 @@ pub struct Encrypted { pub terms: Terms, } -impl + 'static, Terms> DecryptInto

for Encrypted { +impl DecryptInto

for Encrypted { type Context = AeadContext; fn decryption(self, context: Self::Context) -> Decryption { open(self.ciphertext, context) diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 565e370d1..349cc34ce 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -95,7 +95,7 @@ mod source; pub mod transcode; mod tuples; -pub(crate) use self::core::{decipher_pending, seal_pending}; +pub(crate) use self::core::{bind_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, TermSet, diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index ce772f9a9..688a67dbc 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -647,7 +647,7 @@ impl Decryption { } /// The canonical opening operation: retrieve the tree's keys and decode `P` /// through its own Vitamin C `Decrypt` implementation, under `context`. -pub fn open + 'static, K: 'static>( +pub fn open( tree: StackCipherText, context: impl Into, ) -> Decryption { @@ -887,7 +887,7 @@ impl EncryptFrom for StackCipherText { ciphertext() } } -impl + 'static> DecryptInto

for StackCipherText { +impl DecryptInto

for StackCipherText { type Context = AeadContext; fn decryption(self, context: Self::Context) -> Decryption { open(self, context) diff --git a/packages/stack-encrypt/src/target/transcode.rs b/packages/stack-encrypt/src/target/transcode.rs index c99071d09..62a9008c2 100644 --- a/packages/stack-encrypt/src/target/transcode.rs +++ b/packages/stack-encrypt/src/target/transcode.rs @@ -10,7 +10,7 @@ //! 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, MatchTerms, OpeTerm, OreTerm}; -use crate::{BoxedPassthrough, CipherText, Error, SealedValue, StackCipherText}; +use crate::{CipherText, Error, PassthroughFrame, SealedValue, StackCipherText}; /// An encrypted output that can drive a destination visitor. /// @@ -36,7 +36,9 @@ pub trait Reader: Sized { pub trait Visitor: Sized { /// The destination this visitor builds. type Value; - /// One sealed leaf: a scalar's ciphertext. + /// One sealed leaf: a scalar's ciphertext. An absent optional is one + /// too, a sealed null, so opening it under the wrong context fails + /// like any leaf. /// /// # Errors /// @@ -60,15 +62,6 @@ pub trait Visitor: Sized { fn map(self, _: R) -> Result { Err(Error::UnsupportedShape) } - /// The sealed marker of an absent optional. It is ciphertext, not a - /// null: opening it under the wrong context fails like any leaf. - /// - /// # Errors - /// - /// Refuses with [`Error::UnsupportedShape`] unless overridden. - fn absent(self, _: SealedValue) -> Result { - Err(Error::UnsupportedShape) - } /// The sealed marker of an empty sequence. /// /// # Errors @@ -86,12 +79,13 @@ pub trait Visitor: Sized { Err(Error::UnsupportedShape) } /// Passthrough metadata the plaintext carried alongside its encrypted - /// fields. Not authenticated: the reader hands it over as it was given. + /// fields, as the frame of its subtree. Not authenticated: the reader + /// hands it over as it was given. /// /// # Errors /// /// Refuses with [`Error::UnsupportedShape`] unless overridden. - fn passthrough(self, _: BoxedPassthrough) -> Result { + fn passthrough(self, _: PassthroughFrame) -> Result { Err(Error::UnsupportedShape) } /// An equality term. One-way, and not authenticated. @@ -183,7 +177,6 @@ impl Reader for StackCipherText { CipherText::Single(leaf) => visitor.sealed(leaf), CipherText::Sequence(items) => visitor.sequence(items.into_iter()), CipherText::Map(entries) => visitor.map(entries.into_iter()), - CipherText::None(marker) => visitor.absent(marker), CipherText::EmptySequence(marker) => visitor.empty_sequence(marker), CipherText::EmptyMap(marker) => visitor.empty_map(marker), CipherText::Passthrough(value) => visitor.passthrough(value), diff --git a/packages/stack-encrypt/tests/common/mod.rs b/packages/stack-encrypt/tests/common/mod.rs index 8f0af0ea9..472aa44ce 100644 --- a/packages/stack-encrypt/tests/common/mod.rs +++ b/packages/stack-encrypt/tests/common/mod.rs @@ -233,3 +233,20 @@ pub async fn deterministic_cipher(seed: [u8; 32]) -> StackCipher(value: P) -> stack_encrypt::PassthroughFrame { + use vitaminc_protected::Controlled; + stack_encrypt::PassthroughFrame( + vitaminc_aead::to_frame(value) + .expect("a passthrough value frames") + .risky_unwrap(), + ) +} + +/// The `u32` a passthrough payload carries. +pub fn passthrough_u32(payload: &stack_encrypt::PassthroughFrame) -> u32 { + use vitaminc_aead::Decrypt; + let frame = payload.parse().expect("one frame"); + vitaminc_aead::Encrypt::visit(frame, u32::sink()).expect("a u32") +} diff --git a/packages/stack-encrypt/tests/fixtures/README.md b/packages/stack-encrypt/tests/fixtures/README.md index 13c8fa4c4..c7a90d045 100644 --- a/packages/stack-encrypt/tests/fixtures/README.md +++ b/packages/stack-encrypt/tests/fixtures/README.md @@ -11,10 +11,9 @@ same seed. `tests/record_lowering.rs` opens each record with the other author and checks that both derive the terms it holds. That cross-opening is the proof that `dynamic::record` is a lowering into the plan builder and not a second executor (ADR-0007, amended 2026-10-06). A Go test reads the -same file later, once the Go SDK's generated code is the third author. A -chain over bare `u32` or `String` fields derives the same terms but a -different leaf encoding, which the other reader cannot open or tell apart; -`dynamic::record`'s docs say why the lowering does not bridge that. +same file later, once the Go SDK's generated code is the third author. A +chain over bare `u32` or `String` fields seals the same leaves too: every +leaf is tagged with its type (vitaminc ADR 0001), whoever wrote it. Regenerate with `STACK_ENCRYPT_UPDATE_FIXTURES=1 cargo test -p stack-encrypt --all-features --test record_lowering`. Only the sealed bytes diff --git a/packages/stack-encrypt/tests/fixtures/record_lowering.json b/packages/stack-encrypt/tests/fixtures/record_lowering.json index 9aa62a478..b0744958c 100644 --- a/packages/stack-encrypt/tests/fixtures/record_lowering.json +++ b/packages/stack-encrypt/tests/fixtures/record_lowering.json @@ -47,7 +47,7 @@ "passthrough": "tenants/acme" }, "text": { - "c": "0100000000000000000000000000000000cf1a45d1517a08c45d6ae1c989ae99de2000da620d1dbdbba9372d1439b6d44f7cc4169e9a255387aa180d6a95fb17926d7001a7e8ada1c849706c1f1b5c56ad878ea5deb68ebefecb98a8816cbe658c20b33989cc", + "c": "0100000000000000000000000000000000cf1a45d1517a08c45d6ae1c989ae99de2000da620d1dbdbba9372d1439b6d44f7cc4169e9a255387aa180d6a95fb17926d70027eff9bae3dcbbf74666b87b43afcdeeb5d90fe4c4e316142e647677cb9597d6cf9c0", "eq": "0d84fbd44d82b5150351556d6d25a038c70cf0c696e2499a71b659a7732a9d08" } }, @@ -59,7 +59,7 @@ "passthrough": "tenants/acme" }, "text": { - "c": "010000000000000000000000000000000048a99626f697c02001f07da2edb19f4220009868b833b6220fa0d13b612af03af1f38fee082c7555b6e7fbf8e13ccb70f80701dc7cf368a02a3bd466d24b4037597aeae058b641ff5a2c73736f98d8d49ae10a3d9c", + "c": "010000000000000000000000000000000048a99626f697c02001f07da2edb19f4220009868b833b6220fa0d13b612af03af1f38fee082c7555b6e7fbf8e13ccb70f80702c1591a387d3f6fd6c83d4b547b857ec46a3ef86919ace434e5ca69581ce699cf0765", "eq": "0d84fbd44d82b5150351556d6d25a038c70cf0c696e2499a71b659a7732a9d08" } } @@ -125,12 +125,12 @@ "records": { "lowering": { "age": { - "c": "01000000000000000000000000000000004d5626148f77c4c6d9da0ddbdf7663a2200021162641133485216f389886cbd7c6e9b25afa0f5f166f985b23384e81ffcfac010686f7787f57b4c7a2192c3499ea1b890f9f4623d84b72b5f40266b10b7f607a41", + "c": "01000000000000000000000000000000004d5626148f77c4c6d9da0ddbdf7663a2200021162641133485216f389886cbd7c6e9b25afa0f5f166f985b23384e81ffcfac02b8821c190a544161b6ac13825768cd6b494515bdd96c26715ffecd7321277d13d6", "eq": "5433335e609685a33b29c98369e68cd5d92e838aa566a3ff1df5c2d251b63885", "ore": "7ca54e16d7944c0a9d8577d87f3864fdf79e99fea5956ba956a126a605cbf3f1" }, "email": { - "c": "0100000000000000000000000000000000b2afa77357a61488146c0edaa0246f65200043956c4f4bba857028c28165dfce9c63d904b738bbb039b6e11c805112d4a1ee01ec122b5ee8771a6a37fc17312ff3f7a56e93885e19d4bb189950aa37475eb74f63f5758c4f87a3494c5e46ff", + "c": "0100000000000000000000000000000000b2afa77357a61488146c0edaa0246f65200043956c4f4bba857028c28165dfce9c63d904b738bbb039b6e11c805112d4a1ee02d3ce6ecc6cc31f82d07a829142cf39b2fcd9a742f89627f316622416f49df065d381337c5da03a8f3ab5b84d", "eq": "ec534cfbf2336785378c3761ff88623c46361b2ba7c5fa83930ebacae49cf646", "match": "06000d0013001d002200260029002a003000320043005a006000660068006c0077008f00960098009e00c200c500d600d900de00e300e700e900eb00ec00f200fa00" }, @@ -138,17 +138,17 @@ "passthrough": 42 }, "notes": { - "c": "0100000000000000000000000000000000723f7a7f14b07197168aa13bc52010b3200072a73194b45af604cc6201b5a57554b05cc7ed6ad22b9102cbcb71bd9067849601a2ae59c2cfca81b0ecde824090e2331f2c389202676855068652e9e566dfccb9a55e9973a9301c" + "c": "0100000000000000000000000000000000723f7a7f14b07197168aa13bc52010b3200072a73194b45af604cc6201b5a57554b05cc7ed6ad22b9102cbcb71bd90678496020f72b5054a1a4467aa441215b4af28502c55a2590aae2a844ea40392f5fd64e679614a072b8736" } }, "typed_chain": { "age": { - "c": "010000000000000000000000000000000092e427c5e176b60261add6ce80fd02df20008d231453c97fdec97a0b6559ac8f509129d20163f262d40ce5d77cf1ebe0cad501155707def46648a0c3685910bea6d3ec9c63aeb8c1e864092d47a3bd0269d135c9", + "c": "010000000000000000000000000000000092e427c5e176b60261add6ce80fd02df20008d231453c97fdec97a0b6559ac8f509129d20163f262d40ce5d77cf1ebe0cad502c51f3fa24db5e34fadc3553b401d674928b00126a41cb7f30da889259f472223e8", "eq": "5433335e609685a33b29c98369e68cd5d92e838aa566a3ff1df5c2d251b63885", "ore": "7ca54e16d7944c0a9d8577d87f3864fdf79e99fea5956ba956a126a605cbf3f1" }, "email": { - "c": "0100000000000000000000000000000000cf92191950409f9fe82a2b11888ffc1d2000ca4d5bbafbff919af4d3a95c7cc046d632fd4c28785b238f4b6f7ea3aa08482901a3e671679e0e1ac106a28ad8a27e3ae143a6ad5c4b8280dbf6818d1bab32fa45cb0790df8551cec1c9bb3187", + "c": "0100000000000000000000000000000000cf92191950409f9fe82a2b11888ffc1d2000ca4d5bbafbff919af4d3a95c7cc046d632fd4c28785b238f4b6f7ea3aa08482902a44da283fdefa835c68377a191ccf172943720b07e7779cc903619869844eb9cfed80a8a7f9034e69385ddb1", "eq": "ec534cfbf2336785378c3761ff88623c46361b2ba7c5fa83930ebacae49cf646", "match": "06000d0013001d002200260029002a003000320043005a006000660068006c0077008f00960098009e00c200c500d600d900de00e300e700e900eb00ec00f200fa00" }, @@ -156,7 +156,7 @@ "passthrough": 42 }, "notes": { - "c": "0100000000000000000000000000000000562a41e032316ff2d1264a9e9f5c3b842000de33c8d1f2caaab8b90f691161a57eeb1328f7f1c9b5be76dec83e8a53765f11015924202425d1e3be9754306c2e290e350232d5b5623190ebcc69feb55fb566fe4b4db024f1ebcc" + "c": "0100000000000000000000000000000000562a41e032316ff2d1264a9e9f5c3b842000de33c8d1f2caaab8b90f691161a57eeb1328f7f1c9b5be76dec83e8a53765f1102c76bf0a61684b320c316ad94b01eb76bb52b3c8493f21a73012de6289580bf355b7192c61500e0" } } } diff --git a/packages/stack-encrypt/tests/index.rs b/packages/stack-encrypt/tests/index.rs index a61f28078..34a079153 100644 --- a/packages/stack-encrypt/tests/index.rs +++ b/packages/stack-encrypt/tests/index.rs @@ -623,7 +623,8 @@ mod given_encrypted_terms_as_a_target { async fn same_bytes(value: S, indexes: X) where S: stack_encrypt::Encrypt - + for<'a> stack_encrypt::Decrypt<'a> + + stack_encrypt::Decrypt + + Send + Clone + PartialEq + std::fmt::Debug diff --git a/packages/stack-encrypt/tests/plan_grammar.rs b/packages/stack-encrypt/tests/plan_grammar.rs index b56486a57..3e2a77fcc 100644 --- a/packages/stack-encrypt/tests/plan_grammar.rs +++ b/packages/stack-encrypt/tests/plan_grammar.rs @@ -960,7 +960,7 @@ struct One { /// other plan. async fn same_field_bytes(value: F, indexes: X) where - F: Encrypt + Decrypt<'static> + Clone + Send + Sync + PartialEq + Debug + 'static, + F: Encrypt + Decrypt + Clone + Send + Sync + PartialEq + Debug + 'static, Terms: TermSet + PartialEq + Debug + Send, X: Indexes + Clone + Send + Sync + 'static, { diff --git a/packages/stack-encrypt/tests/record_lowering.rs b/packages/stack-encrypt/tests/record_lowering.rs index dce2f8a5c..7f84e5999 100644 --- a/packages/stack-encrypt/tests/record_lowering.rs +++ b/packages/stack-encrypt/tests/record_lowering.rs @@ -3,8 +3,8 @@ //! records the other sealed, and both derive the same bytes for each term. //! The chain's fields are `dynamic::Value`s, the plaintext type a binding's //! values have, so the two authors write one declaration over one type; a -//! chain over bare `u32`/`String` fields shares the terms and not the leaves -//! (see `dynamic::record`). `tests/fixtures/record_lowering.json` holds one +//! chain over bare `u32`/`String` fields seals the same tagged leaves (see +//! `dynamic::record`). `tests/fixtures/record_lowering.json` holds one //! record from each author, sealed under a deterministic key source so the //! bytes open in any process, and its README gives the schema a Go test //! reads later. @@ -178,7 +178,7 @@ fn json_of_record(tree: StackCipherText) -> Json { let value = match (key.as_str(), node) { ("c", CipherText::Single(leaf)) => json!(hex(&leaf.to_bytes())), ("passthrough", CipherText::Passthrough(payload)) => { - match *payload.downcast::().expect("a value") { + match FfiValue::from_frame(&payload.0).expect("a value") { FfiValue::UInt32(v) => json!(v), FfiValue::String(s) => { json!(String::from_utf8(s.risky_ref().to_vec()).expect("utf8")) @@ -187,7 +187,7 @@ fn json_of_record(tree: StackCipherText) -> Json { } } (_, CipherText::Passthrough(payload)) => { - match *payload.downcast::().expect("a value") { + match FfiValue::from_frame(&payload.0).expect("a value") { FfiValue::Bytes(bytes) => json!(hex(bytes.risky_ref())), _ => panic!("a term is bytes"), } @@ -201,6 +201,14 @@ fn json_of_record(tree: StackCipherText) -> Json { Json::Object(out) } +/// A value as a passthrough node: its frame, in the clear. +fn passthrough(value: FfiValue) -> StackCipherText { + use vitaminc_protected::Controlled; + CipherText::Passthrough(stack_encrypt::PassthroughFrame( + value.to_frame().expect("a value frames").risky_unwrap(), + )) +} + fn record_of_json(record: &Json) -> StackCipherText { let fields = record.as_object().expect("an object"); CipherText::Map( @@ -216,20 +224,16 @@ fn record_of_json(record: &Json) -> StackCipherText { SealedValue::from_bytes(&unhex(value.as_str().expect("hex"))) .expect("a frozen leaf"), ), - "passthrough" => CipherText::Passthrough(Box::new(match value { + "passthrough" => passthrough(match value { Json::String(text) => FfiValue::String(text.as_str().into()), _ => FfiValue::UInt32( u32::try_from(value.as_u64().expect("an integer")) .expect("u32"), ), - }) - as stack_encrypt::BoxedPassthrough), - _ => CipherText::Passthrough(Box::new(FfiValue::Bytes( - vitaminc_protected::Protected::new(unhex( - value.as_str().expect("hex"), - )), - )) - as stack_encrypt::BoxedPassthrough), + }), + _ => passthrough(FfiValue::Bytes(vitaminc_protected::Protected::new( + unhex(value.as_str().expect("hex")), + ))), }; (key.clone(), node) }) @@ -248,10 +252,7 @@ fn record_of_typed(mut values: FieldValues) -> StackCipherText { let notes: StackCipherText = values.take("notes").expect("notes"); let id: u32 = values.take("id").expect("id"); let term = |bytes: Vec| -> StackCipherText { - CipherText::Passthrough( - Box::new(FfiValue::Bytes(vitaminc_protected::Protected::new(bytes))) - as stack_encrypt::BoxedPassthrough, - ) + passthrough(FfiValue::Bytes(vitaminc_protected::Protected::new(bytes))) }; CipherText::Map(vec![ ( @@ -275,9 +276,7 @@ fn record_of_typed(mut values: FieldValues) -> StackCipherText { "id".into(), CipherText::Map(vec![( "passthrough".into(), - CipherText::Passthrough( - Box::new(FfiValue::UInt32(id)) as stack_encrypt::BoxedPassthrough - ), + passthrough(FfiValue::UInt32(id)), )]), ), ]) @@ -303,7 +302,7 @@ fn typed_of_record(tree: StackCipherText) -> FieldValues { let CipherText::Passthrough(payload) = node else { panic!("a passthrough node"); }; - let FfiValue::UInt32(id) = *payload.downcast::().expect("a value") + let FfiValue::UInt32(id) = FfiValue::from_frame(&payload.0).expect("a value") else { panic!("a u32"); }; @@ -576,9 +575,6 @@ fn record_of_note(mut values: FieldValues) -> StackCipherText { let tenant: Value = values.take("tenant").expect("tenant"); let text: Encrypted = values.take("text").expect("text"); let id: u32 = values.take("id").expect("id"); - let passthrough = |value: FfiValue| -> StackCipherText { - CipherText::Passthrough(Box::new(value) as stack_encrypt::BoxedPassthrough) - }; CipherText::Map(vec![ ( "tenant".into(), @@ -628,7 +624,7 @@ fn note_of_record(tree: StackCipherText) -> FieldValues { let CipherText::Passthrough(payload) = node else { panic!("a passthrough node"); }; - match *payload.downcast::().expect("a value") { + match FfiValue::from_frame(&payload.0).expect("a value") { FfiValue::UInt32(id) => { let _ = values.insert(&name, id); } diff --git a/packages/stack-encrypt/tests/roundtrip.rs b/packages/stack-encrypt/tests/roundtrip.rs index 985f65792..def78a5a3 100644 --- a/packages/stack-encrypt/tests/roundtrip.rs +++ b/packages/stack-encrypt/tests/roundtrip.rs @@ -6,12 +6,10 @@ use std::collections::HashMap; -use stack_encrypt::{ - BoxedPassthrough, Cipher, CipherText, ContextTag, Element, Encrypt, IntoAad, SealedValue, - StackCipher, -}; +use stack_encrypt::{CipherText, ContextTag, Encrypt, PassthroughFrame, SealedValue, StackCipher}; use stack_kms::FakeDataKeySource; -use vitaminc_aead::{MapCipher, Passthrough}; +use vitaminc_aead::plaintext::{MapSink, Sink}; +use vitaminc_aead::Decrypt; use vitaminc_protected::{Controlled, Protected}; async fn cipher() -> StackCipher { @@ -126,9 +124,8 @@ async fn option_none_roundtrips() { #[tokio::test] async fn protected_roundtrip() { - // Exercises the `Protected` Decrypt impl, the sole user of `Decipher::map_ok`. - // (`Vec` would encrypt element-wise as a sequence of `u8`, not as bytes, - // so a string leaf is used here.) + // Exercises the `Protected` Decrypt impl, which builds the value inside + // the wrapper. let cipher = cipher().await; let keyset = cipher.default_keyset(); let secret = Protected::new("classified".to_string()); @@ -150,12 +147,15 @@ async fn nested_vec_roundtrips() { assert_eq!(pt, nested); } +/// The context a `ContextTag` binds is an AAD like any other: a value +/// sealed under `ContextTag::aad(tag)` opens under the same, and only the +/// same, tag. #[tokio::test] async fn context_tag_binds_and_roundtrips() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt(ContextTag::new("token".to_string(), "user:42"), ()) + .encrypt("token".to_string(), ContextTag::aad("user:42")) .await .expect("encrypt"); @@ -172,7 +172,7 @@ async fn context_tag_wrong_context_fails() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt(ContextTag::new("token".to_string(), "user:42"), ()) + .encrypt("token".to_string(), ContextTag::aad("user:42")) .await .expect("encrypt"); @@ -218,8 +218,7 @@ async fn empty_marker_does_not_decode_under_wrong_aad() { } /// A hand-written record carrying one field in the clear, through the -/// type-erased `passthrough_entry_boxed` a cipher-generic `Encrypt` has to -/// use. +/// sink's passthrough. struct Row { id: u32, email: String, @@ -244,21 +243,26 @@ impl Row { } impl Encrypt for Row { - fn encrypt_with_aad<'a, C, A>(self, cipher: C, aad: A) -> Result - where - C: Cipher, - A: IntoAad<'a>, - { - let mut map = cipher - .encrypt_map(aad) - .passthrough_entry_boxed("id", Box::new(self.id))?; + fn visit(self, sink: S) -> Result { + let mut map = sink.map(None)?; + map.entry("id")?.passthrough(self.id)?; if self.repeat_id { - map = map.passthrough_entry_boxed("id", Box::new(self.id))?; + map.entry("id")?.passthrough(self.id)?; } - map.encrypt_entry("email", self.email)?.end() + self.email.visit(map.entry("email")?)?; + map.end() } } +/// A passthrough payload: the frame of `value`, in the clear. +fn clear(value: u32) -> PassthroughFrame { + PassthroughFrame( + vitaminc_aead::to_frame(value) + .expect("a u32 frames") + .risky_unwrap(), + ) +} + /// A passthrough entry lands in the map as given, beside the sealed ones; /// a key given twice is refused like any repeated map key, since a map the /// cipher would refuse to open must never be produced. @@ -271,10 +275,15 @@ async fn a_passthrough_map_entry_is_stored_in_the_clear_once() { panic!("a record encrypts as a map: {ct:?}"); }; let entry = |name: &str| entries.iter().find(|(key, _)| key == name).map(|(_, v)| v); - assert!( - matches!(entry("id"), Some(CipherText::Passthrough(v)) if v.downcast_ref::() == Some(&7)), - "{entries:?}" - ); + let Some(CipherText::Passthrough(id)) = entry("id") else { + panic!("the id rides in the clear: {entries:?}"); + }; + let id = id + .parse() + .expect("one frame") + .visit(u32::sink()) + .expect("a u32"); + assert_eq!(id, 7, "the passthrough carries the value as given"); assert!( matches!(entry("email"), Some(CipherText::Single(_))), "{entries:?}" @@ -288,12 +297,11 @@ async fn a_passthrough_map_entry_is_stored_in_the_clear_once() { /// none — no entries at all, or only passthroughs, which carry no tag — /// would verify under *any* AAD. The encrypt side never produces one /// (emptiness is the authenticated `Empty*` marker); the decrypt side must -/// refuse to open one, even for a type that reads passthroughs back. +/// refuse to open one, on the tree's shape, before any sink sees it. #[tokio::test] async fn a_container_with_nothing_sealed_in_it_does_not_open() { let cipher = cipher().await; let aad = b"users".as_slice(); - let clear = |value: u32| CipherText::Passthrough(Box::new(value) as BoxedPassthrough); let result: Result, _> = cipher.decrypt(CipherText::Sequence(vec![]), aad).await; assert!(result.is_err(), "an entry-less sequence proves nothing"); @@ -301,15 +309,24 @@ async fn a_container_with_nothing_sealed_in_it_does_not_open() { cipher.decrypt(CipherText::Map(vec![]), aad).await; assert!(result.is_err(), "an entry-less map proves nothing"); - let result: Result>, _> = cipher - .decrypt(CipherText::Sequence(vec![clear(1), clear(2)]), aad) + let result: Result, _> = cipher + .decrypt( + CipherText::Sequence(vec![ + CipherText::Passthrough(clear(1)), + CipherText::Passthrough(clear(2)), + ]), + aad, + ) .await; assert!( result.is_err(), "an all-passthrough sequence proves nothing" ); - let result: Result>, _> = cipher - .decrypt(CipherText::Map(vec![("id".to_string(), clear(1))]), aad) + let result: Result, _> = cipher + .decrypt( + CipherText::Map(vec![("id".to_string(), CipherText::Passthrough(clear(1)))]), + aad, + ) .await; assert!(result.is_err(), "an all-passthrough map proves nothing"); } @@ -360,94 +377,6 @@ async fn sequence_element_cannot_be_rehomed_as_scalar() { ); } -#[tokio::test] -async fn element_roundtrips_under_bare_caller_aad() { - // `Element` derives `for_sequence_element` inside its own Encrypt/Decrypt - // impls. Both sides must honour that derivation: the decipher opens the leaf - // under the AAD the Decrypt drive supplies, not a pre-derived one. - let cipher = cipher().await; - let keyset = cipher.default_keyset(); - let ct = keyset - .encrypt(Element("row".to_string()), b"users".as_slice()) - .await - .expect("encrypt"); - let pt: Element = cipher - .decrypt(ct, b"users".as_slice()) - .await - .expect("Element must round-trip under the bare caller AAD"); - assert_eq!(pt.into_inner(), "row"); -} - -#[tokio::test] -async fn element_interchanges_with_vec_element() { - // A row sealed as one element of a `Vec` decrypts alone as `Element` - // under the same caller AAD (Element's documented use-case), and a lone - // `Element` ciphertext decrypts as a one-element `Vec`. - let cipher = cipher().await; - let keyset = cipher.default_keyset(); - let aad = b"users".as_slice(); - - let ct = keyset - .encrypt(vec!["a".to_string(), "b".to_string()], aad) - .await - .expect("encrypt"); - let second = match ct { - CipherText::Sequence(mut items) => items.remove(1), - other => other, - }; - let pt: Element = cipher - .decrypt(second, aad) - .await - .expect("spliced element must decrypt as Element"); - assert_eq!(pt.into_inner(), "b"); - - let lone = keyset - .encrypt(Element("c".to_string()), aad) - .await - .expect("encrypt"); - let wrapped = CipherText::Sequence(vec![lone]); - let pt: Vec = cipher - .decrypt(wrapped, aad) - .await - .expect("lone Element must decrypt as a one-element Vec"); - assert_eq!(pt, vec!["c".to_string()]); -} - -#[tokio::test] -async fn element_fails_under_wrong_caller_aad() { - let cipher = cipher().await; - let keyset = cipher.default_keyset(); - let ct = keyset - .encrypt(Element("row".to_string()), b"users".as_slice()) - .await - .expect("encrypt"); - let result: Result, _> = cipher.decrypt(ct, b"orders".as_slice()).await; - assert!( - result.is_err(), - "Element under the wrong caller AAD must not decrypt" - ); -} - -/// The derivation that binds a sequence element to its position is the -/// library's, applied by `Element` itself — there is no entry point that -/// lets a caller retrieve keys under one context and authenticate under -/// another, so a batched row is read back by naming the type, not by -/// reconstructing the AAD. -#[tokio::test] -async fn a_batched_element_opens_by_naming_the_type() { - let cipher = cipher().await; - let keyset = cipher.default_keyset(); - let ct = keyset - .encrypt(Element("row".to_string()), b"users".as_slice()) - .await - .expect("encrypt"); - let opened: Element = cipher - .decrypt(ct, b"users".as_slice()) - .await - .expect("Element applies its own derivation on open"); - assert_eq!(opened.into_inner(), "row"); -} - #[tokio::test] async fn wrong_shape_fails() { // A scalar ciphertext must not decode as a sequence. diff --git a/packages/stack-encrypt/tests/source_mode.rs b/packages/stack-encrypt/tests/source_mode.rs index 0489248b0..2988dcfc7 100644 --- a/packages/stack-encrypt/tests/source_mode.rs +++ b/packages/stack-encrypt/tests/source_mode.rs @@ -1,6 +1,6 @@ //! How a description receives its plaintext (`target::{Borrowed, Owned}`). //! -//! Vitamin C's `Encrypt` and `PrfValue` consume their input. A description +//! Vitamin C's by-value `Encrypt` walk and `PrfValue` consume their input. A description //! handed a borrow (the default, and what every `EncryptFrom` declaration //! runs in) clones before an operation consumes; one handed the value //! (`Owned`) gives it to the operation. These tests hold the two claims that @@ -23,7 +23,7 @@ use stack_encrypt::target::{ }; use stack_encrypt::{nonempty, Encrypt, NonEmpty, StackCipherText}; use stack_kms::FakeDataKeySource; -use vitaminc_aead::{Cipher, IntoAad}; +use vitaminc_aead::plaintext::Sink; use vitaminc_prf::{IntoPrfContext, Prf, PrfValue, PrfVisitor}; use vitaminc_protected::{Controlled, Protected}; @@ -53,12 +53,8 @@ impl Secret { } } impl Encrypt for Secret { - fn encrypt_with_aad<'a, C, A>(self, cipher: C, aad: A) -> Result - where - C: Cipher, - A: IntoAad<'a>, - { - self.0.encrypt_with_aad(cipher, aad) + fn visit(self, sink: S) -> Result { + self.0.visit(sink) } } impl PrfValue for Secret { @@ -104,12 +100,8 @@ impl Clone for Counted { } } impl Encrypt for Counted { - fn encrypt_with_aad<'a, C, A>(self, cipher: C, aad: A) -> Result - where - C: Cipher, - A: IntoAad<'a>, - { - self.text.encrypt_with_aad(cipher, aad) + fn visit(self, sink: S) -> Result { + self.text.visit(sink) } } impl PrfValue for Counted { @@ -186,7 +178,7 @@ async fn a_plaintext_that_is_not_clone_derives_a_match_term_alone() { #[cfg(feature = "dynamic")] #[tokio::test] async fn a_zeroizing_ffi_value_seals_through_ciphertext_alone() { - use vitaminc_aead_value::FfiValue; + use stack_encrypt::dynamic::FfiValue; let cipher = stack_cipher().await; let keyset = cipher.default_keyset(); @@ -351,12 +343,8 @@ impl Drop for Dropped { } } impl Encrypt for Dropped { - fn encrypt_with_aad<'a, C, A>(mut self, cipher: C, aad: A) -> Result - where - C: Cipher, - A: IntoAad<'a>, - { - std::mem::take(&mut self.text).encrypt_with_aad(cipher, aad) + fn visit(mut self, sink: S) -> Result { + std::mem::take(&mut self.text).visit(sink) } } impl PrfValue for Dropped { diff --git a/packages/stack-encrypt/tests/transcode.rs b/packages/stack-encrypt/tests/transcode.rs index 23c011cef..bb69b6ec2 100644 --- a/packages/stack-encrypt/tests/transcode.rs +++ b/packages/stack-encrypt/tests/transcode.rs @@ -5,11 +5,12 @@ use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::transcode::{MapReader, Reader, SequenceReader, Transcode, Visitor}; use stack_encrypt::target::{self, CallerContext, ExpectedContext}; use stack_encrypt::{ - nonempty, Cipher, CipherText, ContextPiece, DecryptField, DecryptInto, Decryptable, Decryption, - Encrypt, EncryptFrom, Encryption, Error, IntoAad, IntoContext, MaybeEmpty, NonEmpty, + nonempty, CipherText, ContextPiece, DecryptField, DecryptInto, Decryptable, Decryption, + Encrypt, EncryptFrom, Encryption, Error, IntoContext, MaybeEmpty, NonEmpty, PassthroughFrame, SealedValue, StackCipherText, }; use std::sync::atomic::Ordering; +use vitaminc_aead::plaintext::Sink; #[derive(Clone, Debug, PartialEq)] struct Identifier { @@ -59,7 +60,7 @@ impl EncryptFrom for StoredLeaf { target::ciphertext().accepting().transcode() } } -impl + 'static> DecryptInto

for StoredLeaf { +impl DecryptInto

for StoredLeaf { type Context = CallerContext; fn decryption(self, context: Self::Context) -> Decryption { match SealedValue::from_bytes(&self.0) { @@ -223,12 +224,13 @@ async fn empty_stored_identifier_is_refused_before_any_key_is_retrieved() { #[derive(Clone)] struct WithoutSerde(String); impl Encrypt for WithoutSerde { - fn encrypt_with_aad<'a, C: Cipher, A: IntoAad<'a>>( - self, - cipher: C, - aad: A, - ) -> Result { - self.0.encrypt_with_aad(cipher, aad) + fn visit(self, sink: S) -> Result { + self.0.visit(sink) + } +} +impl Encrypt for &WithoutSerde { + fn visit(self, sink: S) -> Result { + (&self.0).visit(sink) } } #[tokio::test] @@ -248,7 +250,7 @@ async fn ciphertext_uses_plaintexts_native_contract_without_serde() { .unwrap(); assert_eq!( opened, "native", - "a plaintext without Serde seals through its own Encrypt impl and opens canonically" + "a plaintext without Serde seals through its own Plaintext impl and opens canonically" ); let text = String::from("borrowed"); let result: StoredLeaf = keyset @@ -298,7 +300,7 @@ impl EncryptFrom for FixedLeaf { .accepting() } } -impl + 'static> DecryptInto

for FixedLeaf { +impl DecryptInto

for FixedLeaf { type Context = (); fn decryption(self, (): ()) -> Decryption { self.0.decryption(nonempty!("fixed/leaf").into()) @@ -337,10 +339,9 @@ enum Stored { Value(SealedValue), List(Vec), Object(Vec<(String, Stored)>), - Absent(SealedValue), EmptyList(SealedValue), EmptyObject(SealedValue), - Metadata(stack_encrypt::BoxedPassthrough), + Metadata(PassthroughFrame), } struct TreeVisitor; impl Visitor for TreeVisitor { @@ -362,16 +363,13 @@ impl Visitor for TreeVisitor { } Ok(Stored::Object(output)) } - fn absent(self, marker: SealedValue) -> Result { - Ok(Stored::Absent(marker)) - } fn empty_sequence(self, marker: SealedValue) -> Result { Ok(Stored::EmptyList(marker)) } fn empty_map(self, marker: SealedValue) -> Result { Ok(Stored::EmptyObject(marker)) } - fn passthrough(self, value: stack_encrypt::BoxedPassthrough) -> Result { + fn passthrough(self, value: PassthroughFrame) -> Result { Ok(Stored::Metadata(value)) } } @@ -383,7 +381,6 @@ impl Stored { Self::Object(v) => { CipherText::Map(v.into_iter().map(|(k, v)| (k, v.native())).collect()) } - Self::Absent(v) => CipherText::None(v), Self::EmptyList(v) => CipherText::EmptySequence(v), Self::EmptyObject(v) => CipherText::EmptyMap(v), Self::Metadata(v) => CipherText::Passthrough(v), @@ -441,7 +438,7 @@ fn native_readers_report_the_number_of_remaining_entries() { } } - let value = || CipherText::Passthrough(Box::new(7_u32) as stack_encrypt::BoxedPassthrough); + let value = || CipherText::Passthrough(common::passthrough(7_u32)); let sequence: StackCipherText = CipherText::Sequence(vec![value(), value(), value()]); assert_eq!( sequence.read(LengthVisitor).unwrap(), @@ -521,14 +518,14 @@ async fn native_readers_preserve_map_keys_and_authenticated_markers() { opened.is_err(), "an empty-map marker is sealed under its context, not an unauthenticated empty container" ); - let stored = CipherText::Passthrough(Box::new(17u32) as stack_encrypt::BoxedPassthrough) + let stored = CipherText::Passthrough(common::passthrough(17u32)) .read(TreeVisitor) .unwrap(); let Stored::Metadata(value) = stored else { panic!("metadata") }; assert_eq!( - *value.downcast::().unwrap(), + common::passthrough_u32(&value), 17, "passthrough metadata reaches the destination as it was given" ); 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 4a434533e..68a157b03 100644 --- a/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr +++ b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr @@ -14,7 +14,7 @@ help: the trait `DecryptField` is not implemented for `Opaqu `(A, B, C)` implements `DecryptField` `(A, B, C, D)` implements `DecryptField` `(A, B, C, D, E)` implements `DecryptField` - `CipherText>` implements `DecryptField` + `CipherText` implements `DecryptField` `Encrypted` implements `DecryptField` `EqualityTerm` implements `DecryptField` `MatchTerms` implements `DecryptField` @@ -38,7 +38,7 @@ help: the trait `Decryptable` is not implemented for `Opaque` (A, B, C) (A, B, C, D) (A, B, C, D, E) - CipherText> + CipherText Encrypted EqualityTerm MatchTerms diff --git a/packages/stack-encrypt/tests/ui/drop_record.stderr b/packages/stack-encrypt/tests/ui/drop_record.stderr index acf842fcd..c3667678c 100644 --- a/packages/stack-encrypt/tests/ui/drop_record.stderr +++ b/packages/stack-encrypt/tests/ui/drop_record.stderr @@ -5,4 +5,4 @@ error[E0509]: cannot move out of type `Rec`, which implements the `Drop` trait | ^^^^^^^^^^^^^^^^^^ | | | cannot move out of here - | move occurs because value has type `CipherText>`, which does not implement the `Copy` trait + | move occurs because value has type `CipherText`, which does not implement the `Copy` trait diff --git a/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.rs b/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.rs index 8c808dca1..e567cfd8d 100644 --- a/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.rs +++ b/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.rs @@ -3,7 +3,7 @@ //! one place a copy is unavoidable — the first side gets a clone, the last //! the value — and the refusal names `zip`, not the operations. use stack_encrypt::target::{ciphertext, equality, CallerContext, Owned}; -use stack_encrypt::{nonempty, Encrypt, KeysetCipher}; +use stack_encrypt::{nonempty, KeysetCipher, Encrypt}; use stack_kms::FakeDataKeySource; fn seal_and_index( 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 4ae01d79f..b871b992f 100644 --- a/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs +++ b/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs @@ -5,7 +5,7 @@ use stack_encrypt::sem::{CllwOpeEncrypt, CllwOreEncrypt, EqualityTerm, MatchTerm use stack_encrypt::target::{ ciphertext, equality, matching, ope, ore, CallerContext, Owned, Pending, }; -use stack_encrypt::{nonempty, Encrypt, KeysetCipher, StackCipherText}; +use stack_encrypt::{nonempty, KeysetCipher, Encrypt, StackCipherText}; use stack_kms::FakeDataKeySource; fn seal<'a, S: Encrypt>( diff --git a/packages/stack-encrypt/tests/ui/pass/query_only_and_borrowed.rs b/packages/stack-encrypt/tests/ui/pass/query_only_and_borrowed.rs index 3ff7a83e1..78f5c82f6 100644 --- a/packages/stack-encrypt/tests/ui/pass/query_only_and_borrowed.rs +++ b/packages/stack-encrypt/tests/ui/pass/query_only_and_borrowed.rs @@ -5,7 +5,7 @@ use stack_kms::FakeDataKeySource; #[derive(EncryptFrom)] struct Probe { hm: EqualityTerm } -// No Encrypt bound: producing a term requires only the PRF capability. +// No Plaintext bound: producing a term requires only the PRF capability. fn query(cipher:&StackCipher, value:&S) { let keyset=cipher.default_keyset(); let _ = keyset.encrypt_as::<_,Probe>(value,nonempty!("column").into()); diff --git a/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr index dd4c11198..e08fd10d4 100644 --- a/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr +++ b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr @@ -11,17 +11,28 @@ help: the trait `stack_encrypt::Encrypt` is not implemented for `SerdeOnly` | 4 | struct SerdeOnly { value: String } | ^^^^^^^^^^^^^^^^ +note: there are multiple different versions of crate `vitaminc_aead` in the dependency graph + --> $CARGO/vitaminc-aead-$VERSION/src/plaintext/mod.rs + | + | pub trait Encrypt { + | ^^^^^^^^^^^^^^^^^ this is the expected trait + | + ::: $CARGO/vitaminc-aead-$VERSION/src/encrypt/mod.rs + | + | pub trait Encrypt { + | ----------------- this is the trait that was imported + = help: you can use `cargo tree` to explore your dependency tree = help: the following other types implement trait `stack_encrypt::Encrypt`: - &str - ContextTag - FfiValue - HashMap - Vec - Vec - [u8; N] - stack_encrypt::Element + &&'b T + &'a BTreeMap + &'a HashMap + &'a Vec + &'a [T] + &'a std::option::Option + &'a vitaminc_protected::protected::Protected + &FfiValue and $N others - = note: required for `CipherText>` to implement `EncryptFrom` + = note: required for `CipherText` to implement `EncryptFrom` = note: 1 redundant requirement hidden = note: required for `Target` to implement `EncryptFrom` note: required by a bound in `target::operations::>::encrypt_as` diff --git a/packages/stack-encrypt/tests/ui/struct_field_must_open.rs b/packages/stack-encrypt/tests/ui/struct_field_must_open.rs index 5394b539f..dfa8c9837 100644 --- a/packages/stack-encrypt/tests/ui/struct_field_must_open.rs +++ b/packages/stack-encrypt/tests/ui/struct_field_must_open.rs @@ -1,13 +1,29 @@ //! Every field of a `struct = ..` record opens back into the plaintext //! field it is derived from (`DecryptField`), even when //! the record derives `EncryptFrom` alone: the record's plan seals and -//! opens each field. A borrowed plaintext field cannot be opened into, and -//! a target with no `DecryptField` cannot open. +//! opens each field. A plaintext field with no `Decrypt`, such as one that +//! borrows its text, cannot be opened into, and a target with no +//! `DecryptField` cannot open. use stack_encrypt::target::{Decryptable, Encryption, AeadContext}; -use stack_encrypt::{EncryptFrom, StackCipherText}; +use stack_encrypt::{EncryptFrom, Encrypt, StackCipherText}; +use vitaminc_aead::plaintext::Sink; + +/// Text it borrows: it seals, and there is nothing to open it into. +#[derive(Clone)] +struct Name(&'static str); +impl Encrypt for Name { + fn visit(self, sink: S) -> Result { + self.0.visit(sink) + } +} +impl Encrypt for &Name { + fn visit(self, sink: S) -> Result { + self.0.visit(sink) + } +} struct Profile { - name: &'static str, + name: Name, bio: String, } diff --git a/packages/stack-encrypt/tests/ui/struct_field_must_open.stderr b/packages/stack-encrypt/tests/ui/struct_field_must_open.stderr index 40c9d95bf..59383c476 100644 --- a/packages/stack-encrypt/tests/ui/struct_field_must_open.stderr +++ b/packages/stack-encrypt/tests/ui/struct_field_must_open.stderr @@ -1,18 +1,27 @@ -error[E0277]: the trait bound `&str: Decrypt<'static>` is not satisfied - --> tests/ui/struct_field_must_open.rs:29:11 - | -29 | name: StackCipherText, - | ^^^^^^^^^^^^^^^ the trait `Decrypt<'static>` is not implemented for `&str` - | -help: the trait `Decrypt<'static>` is not implemented for `&str` - but trait `Decrypt<'_>` is implemented for `std::string::String` - --> $CARGO/vitaminc-aead-$VERSION/src/decrypt/impls.rs - | - | impl<'c> Decrypt<'c> for String { - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - = help: for that trait implementation, expected `std::string::String`, found `&str` - = note: required for `CipherText>` to implement `DecryptInto<&str>` - = note: required for `CipherText>` to implement `DecryptField<&str, CallerContext>` +error[E0277]: the trait bound `Name: FromScalar` is not satisfied + --> tests/ui/struct_field_must_open.rs:45:11 + | +45 | name: StackCipherText, + | ^^^^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `FromScalar` is not implemented for `Name` + --> tests/ui/struct_field_must_open.rs:13:1 + | +13 | struct Name(&'static str); + | ^^^^^^^^^^^ + = help: the following other types implement trait `FromScalar`: + Vec + [u8; N] + bool + f32 + f64 + i128 + i16 + i32 + and $N others + = note: required for `Name` to implement `Decrypt` + = note: required for `CipherText` to implement `DecryptInto` + = note: required for `CipherText` to implement `DecryptField` note: required by a bound in `FieldsBuilder::::encrypt_into` --> src/plan/build.rs | @@ -23,22 +32,22 @@ note: required by a bound in `FieldsBuilder::::encrypt_into` | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `FieldsBuilder::::encrypt_into` error[E0277]: the trait bound `Opaque: DecryptField` is not satisfied - --> tests/ui/struct_field_must_open.rs:30:10 + --> tests/ui/struct_field_must_open.rs:46:10 | -30 | bio: Opaque, +46 | bio: Opaque, | ^^^^^^ unsatisfied trait bound | help: the trait `DecryptField` is not implemented for `Opaque` - --> tests/ui/struct_field_must_open.rs:15:1 + --> tests/ui/struct_field_must_open.rs:31:1 | -15 | struct Opaque(#[allow(dead_code)] StackCipherText); +31 | struct Opaque(#[allow(dead_code)] StackCipherText); | ^^^^^^^^^^^^^ = help: the following other types implement trait `DecryptField`: `(A, B)` implements `DecryptField` `(A, B, C)` implements `DecryptField` `(A, B, C, D)` implements `DecryptField` `(A, B, C, D, E)` implements `DecryptField` - `CipherText>` implements `DecryptField` + `CipherText` implements `DecryptField` `Encrypted` implements `DecryptField` `EqualityTerm` implements `DecryptField` `MatchTerms` implements `DecryptField` diff --git a/packages/stack-guest-abi/Cargo.toml b/packages/stack-guest-abi/Cargo.toml index 3d3179922..a7ed2db77 100644 --- a/packages/stack-guest-abi/Cargo.toml +++ b/packages/stack-guest-abi/Cargo.toml @@ -16,18 +16,18 @@ publish = false [dependencies] zeroize = { workspace = true } # `last_error`: an error is any miette diagnostic plus its structured fields -# (a serde_json map, as `ErrorPayload::payload` gives them), encoded in the -# one transport codec every input and output already uses. +# (a serde_json map, as `ErrorPayload::payload` gives them), encoded as a +# `HostValue` in the one transport frame every input and output uses. miette = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } -vitaminc-aead-value = { workspace = true } - -# Only `transport` (wasm32-only) needs these: the import's error type and -# `OpaqueDebug` for its response, whose body can carry wrapped key material -# or a credential and so never prints. Declared for that target alone, so a -# native build carries nothing it does not use and `cargo udeps` sees none. -[target.'cfg(target_arch = "wasm32")'.dependencies] +# `value`: a `HostValue` describes itself to vitaminc's plaintext model and +# crosses to the host as a transport frame. +vitaminc-aead = { workspace = true } +vitaminc-aead-frame = { workspace = true } +# `HostValue::Bytes` holds its bytes in `Protected`, and `transport` +# (wasm32-only) uses `OpaqueDebug` for its response, whose body can carry +# wrapped key material or a credential and so never prints. vitaminc-protected = { workspace = true } [dev-dependencies] diff --git a/packages/stack-guest-abi/src/abi.rs b/packages/stack-guest-abi/src/abi.rs index 90d363e35..bc04e172e 100644 --- a/packages/stack-guest-abi/src/abi.rs +++ b/packages/stack-guest-abi/src/abi.rs @@ -71,7 +71,7 @@ pub fn export(f: impl FnOnce() -> Result, u32>) -> u64 { } /// The full error behind the most recent failed export, as a packed buffer -/// result: a transport-codec object of `code`, `message`, `help`, `url` +/// result: a transport-frame map of `code`, `message`, `help`, `url` /// (when set), `severity`, `fields` and `causes` (see /// [`last_error::encode`]). Zero when there is none — no export has failed /// since the last success, or the error was already handed over. diff --git a/packages/stack-guest-abi/src/call.rs b/packages/stack-guest-abi/src/call.rs index a215404c0..c1b5bdc49 100644 --- a/packages/stack-guest-abi/src/call.rs +++ b/packages/stack-guest-abi/src/call.rs @@ -93,7 +93,7 @@ pub fn take_last_error() -> Option> { mod tests { use super::*; use crate::status::{STATUS_ENCODING, STATUS_INTERNAL}; - use vitaminc_aead_value::{transport as codec, FfiValue}; + use crate::value::HostValue; /// A packed result, read the way the Go host reads it. #[derive(Debug, PartialEq)] @@ -117,16 +117,8 @@ mod tests { /// The recorded error's code, handed over as the host would get it. fn last_code() -> Option { let bytes = take_last_error()?; - let value = codec::decode_value(&mut codec::Reader::new(&bytes)).expect("decodes"); - let FfiValue::Object(entries) = value else { - return None; - }; - entries.into_iter().find_map(|(key, value)| match value { - FfiValue::String(code) if key == "code" => { - String::from_utf8(code.risky_ref().to_vec()).ok() - } - _ => None, - }) + let value = HostValue::decode(&bytes).expect("decodes"); + value.get("code")?.as_text().map(str::to_owned) } #[test] diff --git a/packages/stack-guest-abi/src/last_error.rs b/packages/stack-guest-abi/src/last_error.rs index ff8ed8c1d..89db704f4 100644 --- a/packages/stack-guest-abi/src/last_error.rs +++ b/packages/stack-guest-abi/src/last_error.rs @@ -5,7 +5,7 @@ //! the fast path a host acts on, byte-compatible with the vitaminc guest. //! Beside it, the export records the error that produced the number — its //! miette code, message, help, URL, severity, structured fields and causes — -//! as one value in the transport codec every input and output already uses +//! as one value in the transport frame every input and output already uses //! ([`encode`]). A host that wants the detail calls `se_last_error` after a //! non-zero status; a call that succeeds pays nothing. //! @@ -43,10 +43,10 @@ use std::cell::Cell; use std::error::Error; use miette::{Diagnostic, Severity}; -use vitaminc_aead_value::{transport as codec, FfiValue}; use crate::buffers; use crate::status::{STATUS_ENCODING, STATUS_INTERNAL, STATUS_STATE}; +use crate::value::HostValue; thread_local! { /// The registered buffer holding the encoded last error, if any, and @@ -82,7 +82,7 @@ pub const UNDESCRIBED_CAUSE: &str = "an error from another library"; #[non_exhaustive] pub enum GuestError { /// The input failed the export's own validation before any library - /// saw it: bytes that are not the transport codec, a pointer/length + /// saw it: bytes that are not a transport frame, a pointer/length /// pair outside linear memory, a config or option of the wrong shape. #[error("malformed input: {0}")] #[diagnostic(code(stack_guest_abi::malformed_input))] @@ -191,7 +191,7 @@ pub fn encode( error: &dyn Diagnostic, fields: serde_json::Map, describe: &dyn Fn(&(dyn Error + 'static)) -> Option, -) -> FfiValue { +) -> HostValue { let mut entries = Vec::with_capacity(7); if let Some(code) = error.code() { entries.push(("code".to_owned(), text(code.to_string()))); @@ -211,18 +211,13 @@ pub fn encode( entries.push(("severity".to_owned(), text(severity))); entries.push(( "fields".to_owned(), - FfiValue::Object( - fields - .into_iter() - .map(|(key, value)| (key, json_value(value))) - .collect(), - ), + HostValue::from_json(serde_json::Value::Object(fields)), )); entries.push(( "causes".to_owned(), - FfiValue::Array(causes(error, describe)), + HostValue::List(causes(error, describe)), )); - FfiValue::Object(entries) + HostValue::Map(entries) } /// One link of the cause chain. @@ -239,7 +234,7 @@ enum Cause<'a> { fn causes( error: &dyn Diagnostic, describe: &dyn Fn(&(dyn Error + 'static)) -> Option, -) -> Vec { +) -> Vec { let mut out = Vec::new(); let mut next = next_of(error); while let Some(cause) = next { @@ -263,7 +258,7 @@ fn causes( entry.push(("code".to_owned(), text(code))); } entry.push(("message".to_owned(), text(message))); - out.push(FfiValue::Object(entry)); + out.push(HostValue::Map(entry)); next = after; } out @@ -279,35 +274,8 @@ fn next_of(diagnostic: &dyn Diagnostic) -> Option> { } /// A string leaf. -fn text(value: impl Into) -> FfiValue { - FfiValue::String(value.into().into()) -} - -/// A structured field as a transport value. -fn json_value(value: serde_json::Value) -> FfiValue { - match value { - serde_json::Value::Null => FfiValue::Null, - serde_json::Value::Bool(value) => FfiValue::Bool(value), - serde_json::Value::Number(number) => { - if let Some(value) = number.as_u64() { - FfiValue::UInt64(value) - } else if let Some(value) = number.as_i64() { - FfiValue::Int64(value) - } else { - FfiValue::Float64(number.as_f64().unwrap_or(f64::NAN)) - } - } - serde_json::Value::String(value) => text(value), - serde_json::Value::Array(items) => { - FfiValue::Array(items.into_iter().map(json_value).collect()) - } - serde_json::Value::Object(entries) => FfiValue::Object( - entries - .into_iter() - .map(|(key, value)| (key, json_value(value))) - .collect(), - ), - } +fn text(value: impl Into) -> HostValue { + HostValue::text(value) } /// Record `error` as the last error for `status`, with its structured @@ -331,14 +299,14 @@ pub fn record_with( describe: &dyn Fn(&(dyn Error + 'static)) -> Option, status: u32, ) -> u32 { - let mut bytes = Vec::new(); // An error that does not encode (it cannot: every leaf is a string, a - // number or a container) leaves the last error empty rather than half - // written; the status still says what happened. - if codec::encode_value(encode(error, fields, describe), &mut bytes).is_err() { + // number or a container, and every key is distinct) leaves the last + // error empty rather than half written; the status still says what + // happened. + let Ok(bytes) = encode(error, fields, describe).encode() else { clear(); return status; - } + }; store(bytes, status); status } @@ -417,22 +385,16 @@ pub fn take() -> Option> { mod tests { use super::*; - fn decode(bytes: &[u8]) -> FfiValue { - codec::decode_value(&mut codec::Reader::new(bytes)).expect("an encoded error decodes") + fn decode(bytes: &[u8]) -> HostValue { + HostValue::decode(bytes).expect("an encoded error decodes") } - fn get<'a>(value: &'a FfiValue, key: &str) -> Option<&'a FfiValue> { - match value { - FfiValue::Object(entries) => entries.iter().find(|(k, _)| k == key).map(|(_, v)| v), - _ => None, - } + fn get<'a>(value: &'a HostValue, key: &str) -> Option<&'a HostValue> { + value.get(key) } - fn string(value: Option<&FfiValue>) -> Option { - match value? { - FfiValue::String(s) => String::from_utf8(s.risky_ref().to_vec()).ok(), - _ => None, - } + fn string(value: Option<&HostValue>) -> Option { + value?.as_text().map(str::to_owned) } #[derive(Debug, thiserror::Error, Diagnostic)] @@ -473,9 +435,9 @@ mod tests { assert_eq!(string(get(&value, "severity")).as_deref(), Some("error")); assert!(matches!( get(get(&value, "fields").expect("fields"), "count"), - Some(FfiValue::UInt64(2)) + Some(HostValue::UInt(2)) )); - let Some(FfiValue::Array(causes)) = get(&value, "causes") else { + let Some(HostValue::List(causes)) = get(&value, "causes") else { panic!("causes is a list"); }; assert_eq!(causes.len(), 2); @@ -504,7 +466,7 @@ mod tests { struct Wrapper(#[source] Foreign); let _ = record(&Wrapper(Foreign), serde_json::Map::new(), STATUS_INTERNAL); let value = decode(&take().expect("recorded")); - let Some(FfiValue::Array(causes)) = get(&value, "causes") else { + let Some(HostValue::List(causes)) = get(&value, "causes") else { panic!("causes is a list"); }; assert_eq!( @@ -550,7 +512,7 @@ mod tests { ); assert!(matches!( get(get(&value, "fields").expect("fields"), "status"), - Some(FfiValue::UInt64(42)) + Some(HostValue::UInt(42)) )); } diff --git a/packages/stack-guest-abi/src/lib.rs b/packages/stack-guest-abi/src/lib.rs index f635c2855..6b4db1a3f 100644 --- a/packages/stack-guest-abi/src/lib.rs +++ b/packages/stack-guest-abi/src/lib.rs @@ -54,8 +54,8 @@ //! both are tested natively and the guests' leak tests run the real //! lifecycle. //! - [`last_error`] — the full error behind the most recent failed export: -//! its code, message, help, fields and causes, encoded once in the -//! transport codec for `se_last_error`. The status number stays the fast +//! its code, message, help, fields and causes, encoded once as a +//! transport frame for `se_last_error`. The status number stays the fast //! path; this is the detail a host asks for after it. //! - [`status`] — the status table. **One numbering for every guest**: the //! codes below keep their values for good, and a guest that needs more @@ -65,6 +65,8 @@ //! call and gets both response buffers back through the registry. //! - [`headers`] — the `name: value` line format request and response //! headers cross the import in. +//! - [`value`] — [`HostValue`](value::HostValue), a value a guest hands its +//! host, and the transport frame it crosses as. //! //! What is deliberately *not* here: anything that names a crate a guest is //! built over. The ZeroKMS connection over the transport, the token import @@ -102,6 +104,7 @@ pub mod call; pub mod headers; pub mod last_error; pub mod status; +pub mod value; #[cfg(target_arch = "wasm32")] pub mod abi; diff --git a/packages/stack-guest-abi/src/value.rs b/packages/stack-guest-abi/src/value.rs new file mode 100644 index 000000000..a350c3cde --- /dev/null +++ b/packages/stack-guest-abi/src/value.rs @@ -0,0 +1,393 @@ +//! [`HostValue`]: a value a guest hands its host, and how it crosses the +//! boundary. +//! +//! Every structured output a guest returns (a list of workspace ids, the +//! fields of a token, the last error) crosses as one transport frame, the +//! encoding vitaminc's `Frame` and `FrameWriter` define and the Go module +//! reads with `vcffi`. A `HostValue` describes itself to vitaminc's +//! [`Encrypt`] model, so [`encode`](HostValue::encode) is vitaminc's own +//! frame writer, and [`decode`](HostValue::decode) reads a frame back +//! through a [`Sink`], for the tests that check what a host is given. + +use vitaminc_aead::plaintext::{ + visit_map, visit_seq, Encrypt, Float, Logical, MapSink, Physical, SeqSink, Sink, SourceError, +}; +use vitaminc_aead_frame::{to_frame, Frame, FrameError, FrameWriteError}; +use vitaminc_protected::{Controlled, Protected}; +use zeroize::Zeroizing; + +/// A value a guest hands its host: a scalar, a list or a map with string +/// keys. +/// +/// Integers are 64-bit, unsigned where the value cannot be negative, so the +/// host reads one integer type per field. Bytes are held in [`Protected`], +/// as a client key is, and wiped when the value drops. +pub enum HostValue { + /// No value. + Null, + /// A boolean. + Bool(bool), + /// An unsigned integer. + UInt(u64), + /// A signed integer. + Int(i64), + /// A float. + Float(f64), + /// Text, wiped when dropped: a guest hands secrets back as text (an + /// access token), and the guest heap is not cleared when it frees. + Text(Zeroizing), + /// Bytes. + Bytes(Protected>), + /// A list. + List(Vec), + /// A map, in the order its entries were given. + Map(Vec<(String, HostValue)>), +} + +impl HostValue { + /// Text, from anything that converts to a `String`. + pub fn text(value: impl Into) -> Self { + Self::Text(Zeroizing::new(value.into())) + } + + /// A structured field as a host value: a JSON number becomes an + /// unsigned integer if it is one, then a signed integer, then a float. + pub fn from_json(value: serde_json::Value) -> Self { + match value { + serde_json::Value::Null => Self::Null, + serde_json::Value::Bool(value) => Self::Bool(value), + serde_json::Value::Number(number) => { + if let Some(value) = number.as_u64() { + Self::UInt(value) + } else if let Some(value) = number.as_i64() { + Self::Int(value) + } else { + Self::Float(number.as_f64().unwrap_or(f64::NAN)) + } + } + serde_json::Value::String(value) => Self::Text(Zeroizing::new(value)), + serde_json::Value::Array(items) => { + Self::List(items.into_iter().map(Self::from_json).collect()) + } + serde_json::Value::Object(entries) => Self::Map( + entries + .into_iter() + .map(|(key, value)| (key, Self::from_json(value))) + .collect(), + ), + } + } + + /// This value as a transport frame. + /// + /// The frame is returned bare, as every guest output is: it becomes a + /// registered buffer, which the registry wipes when the host releases + /// it. Its capacity is its length: registering a `Vec` with spare + /// capacity reallocates it (`into_boxed_slice`), and the allocation left + /// behind is not wiped. So the frame is copied into an exactly sized + /// buffer, and the writer's own buffer is wiped when it drops. + /// + /// # Errors + /// + /// [`FrameWriteError`] for a map with a repeated key or a value nested + /// deeper than a frame carries. A guest builds its outputs itself, so + /// either is a bug in the guest. + pub fn encode(&self) -> Result, FrameWriteError> { + let frame = to_frame(self)?; + let mut out = Vec::with_capacity(frame.risky_ref().len()); + out.extend_from_slice(frame.risky_ref()); + Ok(out) + } + + /// Read one value from a transport frame. + /// + /// # Errors + /// + /// [`DecodeError`] when the bytes are not exactly one frame, or the + /// frame holds a scalar a `HostValue` does not (a 32-bit float, a date). + pub fn decode(bytes: &[u8]) -> Result { + let frame = Frame::parse(bytes).map_err(DecodeError::Frame)?; + frame.visit(ValueSink(Root)) + } + + /// The entry under `key`, if this is a map that has one. + pub fn get(&self, key: &str) -> Option<&HostValue> { + match self { + Self::Map(entries) => entries.iter().find(|(k, _)| k == key).map(|(_, v)| v), + _ => None, + } + } + + /// The text, if this is text. + pub fn as_text(&self) -> Option<&str> { + match self { + Self::Text(text) => Some(text.as_str()), + _ => None, + } + } +} + +/// Why [`HostValue::decode`] refused its bytes. +#[derive(Debug, thiserror::Error)] +pub enum DecodeError { + /// The bytes are not exactly one well-formed frame. + #[error(transparent)] + Frame(FrameError), + /// The frame holds a scalar no `HostValue` holds. + #[error("the frame holds a scalar a host value does not")] + Scalar, + /// The frame's source failed (it cannot: a parsed frame is complete). + #[error("the frame could not be read")] + Source, +} + +impl vitaminc_aead::plaintext::Error for DecodeError { + fn source(_: SourceError) -> Self { + Self::Source + } +} + +/// Walks the value, leaving it where it is. +impl Encrypt for &HostValue { + fn visit(self, sink: S) -> Result { + match self { + HostValue::Null => sink.scalar(None, Physical::Null), + HostValue::Bool(b) => sink.scalar(None, Physical::Bool(*b)), + HostValue::UInt(u) => sink.scalar(None, Physical::Int((*u).into())), + HostValue::Int(i) => sink.scalar(None, Physical::Int((*i).into())), + HostValue::Float(f) => sink.scalar(None, Physical::Float(Float::F64(*f))), + HostValue::Text(text) => sink.scalar(None, Physical::Text(text.as_str())), + HostValue::Bytes(bytes) => sink.scalar(None, Physical::Bytes(bytes.risky_ref())), + HostValue::List(items) => visit_seq(sink, Some(items.len()), items), + HostValue::Map(entries) => visit_map( + sink, + Some(entries.len()), + entries.iter().map(|(key, value)| (key.as_str(), value)), + ), + } + } +} + +/// Where a finished value goes: back to the caller, or into its parent. +trait Put { + type Ok; + fn put(self, value: HostValue) -> Self::Ok; +} + +struct Root; + +impl Put for Root { + type Ok = HostValue; + fn put(self, value: HostValue) -> HostValue { + value + } +} + +struct Element<'s>(&'s mut Vec); + +impl Put for Element<'_> { + type Ok = (); + fn put(self, value: HostValue) { + self.0.push(value); + } +} + +struct Entry<'s>(&'s mut Vec<(String, HostValue)>, String); + +impl Put for Entry<'_> { + type Ok = (); + fn put(self, value: HostValue) { + self.0.push((self.1, value)); + } +} + +/// Builds the value a frame describes. +struct ValueSink(O); + +impl Sink for ValueSink { + type Ok = O::Ok; + type Error = DecodeError; + type Seq = ValueSeq; + type Map = ValueMap; + + fn scalar(self, ty: Option, value: Physical<'_>) -> Result { + let value = match (ty, value) { + (None, Physical::Null) => HostValue::Null, + (None, Physical::Bool(b)) => HostValue::Bool(b), + (None, Physical::Int(int)) => { + if let Some(u) = int.to_u128().and_then(|u| u64::try_from(u).ok()) { + HostValue::UInt(u) + } else if let Some(i) = int.to_i128().and_then(|i| i64::try_from(i).ok()) { + HostValue::Int(i) + } else { + return Err(DecodeError::Scalar); + } + } + (None, Physical::Float(Float::F64(f))) => HostValue::Float(f), + (None, Physical::Text(text)) => HostValue::Text(Zeroizing::new(text.to_owned())), + (None, Physical::Bytes(bytes)) => HostValue::Bytes(Protected::new(bytes.to_vec())), + _ => return Err(DecodeError::Scalar), + }; + Ok(self.0.put(value)) + } + + fn seq(self, _: Option) -> Result, DecodeError> { + Ok(ValueSeq { + out: self.0, + items: Vec::new(), + }) + } + + fn map(self, _: Option) -> Result, DecodeError> { + Ok(ValueMap { + out: self.0, + entries: Vec::new(), + }) + } + + /// A guest never hands its host a passthrough. + fn passthrough(self, _: P) -> Result { + Err(DecodeError::Scalar) + } +} + +struct ValueSeq { + out: O, + items: Vec, +} + +impl SeqSink for ValueSeq { + type Ok = O::Ok; + type Error = DecodeError; + type Element<'s> + = ValueSink> + where + Self: 's; + + fn element(&mut self) -> Result>, DecodeError> { + Ok(ValueSink(Element(&mut self.items))) + } + + fn end(self) -> Result { + Ok(self.out.put(HostValue::List(self.items))) + } +} + +struct ValueMap { + out: O, + entries: Vec<(String, HostValue)>, +} + +impl MapSink for ValueMap { + type Ok = O::Ok; + type Error = DecodeError; + type Entry<'s> + = ValueSink> + where + Self: 's; + + fn entry(&mut self, key: &str) -> Result>, DecodeError> { + Ok(ValueSink(Entry(&mut self.entries, key.to_owned()))) + } + + fn end(self) -> Result { + Ok(self.out.put(HostValue::Map(self.entries))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// An encoded frame has no spare capacity, so registering it does not + /// reallocate and leave an unwiped copy behind. + #[test] + fn an_encoded_frame_has_no_spare_capacity() { + let out = HostValue::Map(vec![( + "access_token".to_string(), + HostValue::text("a-bearer-token"), + )]) + .encode() + .unwrap(); + assert_eq!(out.capacity(), out.len()); + } + + /// Text is held as `Zeroizing`, so a secret a guest hands back + /// as text (the auth guest's access token) is wiped when the value is + /// dropped, by every path that builds one: the constructor, JSON and a + /// decoded frame. A plain `String` here would leave it in freed guest + /// memory. + #[test] + fn text_is_wiped_on_drop() { + fn held(value: HostValue) -> Zeroizing { + match value { + HostValue::Text(text) => text, + _ => panic!("not text"), + } + } + assert_eq!(held(HostValue::text("token")).as_str(), "token"); + assert_eq!( + held(HostValue::from_json(serde_json::json!("token"))).as_str(), + "token" + ); + let frame = HostValue::text("token").encode().unwrap(); + assert_eq!(held(HostValue::decode(&frame).unwrap()).as_str(), "token"); + } + + #[test] + fn a_value_reads_back_as_it_was_written() { + let value = HostValue::Map(vec![ + ("null".into(), HostValue::Null), + ("yes".into(), HostValue::Bool(true)), + ("big".into(), HostValue::UInt(u64::MAX)), + ("negative".into(), HostValue::Int(-2)), + ("half".into(), HostValue::Float(0.5)), + ("name".into(), HostValue::text("alice")), + ("key".into(), HostValue::Bytes(Protected::new(vec![1, 2]))), + ( + "ids".into(), + HostValue::List(vec![HostValue::text("a"), HostValue::List(vec![])]), + ), + ]); + let bytes = value.encode().expect("encodes"); + let back = HostValue::decode(&bytes).expect("decodes"); + assert!(matches!(back.get("null"), Some(HostValue::Null))); + assert!(matches!(back.get("yes"), Some(HostValue::Bool(true)))); + assert!(matches!(back.get("big"), Some(HostValue::UInt(u64::MAX)))); + assert!(matches!(back.get("negative"), Some(HostValue::Int(-2)))); + assert!(matches!(back.get("half"), Some(HostValue::Float(f)) if *f == 0.5)); + assert_eq!(back.get("name").and_then(HostValue::as_text), Some("alice")); + assert!(matches!(back.get("key"), Some(HostValue::Bytes(b)) if b.risky_ref() == &[1, 2])); + assert!(matches!(back.get("ids"), Some(HostValue::List(items)) if items.len() == 2)); + } + + #[test] + fn a_map_with_a_repeated_key_does_not_encode() { + let value = HostValue::Map(vec![ + ("a".into(), HostValue::Null), + ("a".into(), HostValue::Null), + ]); + assert!(value.encode().is_err()); + } + + #[test] + fn bytes_that_are_not_one_frame_do_not_decode() { + assert!(matches!(HostValue::decode(&[]), Err(DecodeError::Frame(_)))); + } + + #[test] + fn json_numbers_keep_their_integer_type() { + assert!(matches!( + HostValue::from_json(serde_json::json!(2)), + HostValue::UInt(2) + )); + assert!(matches!( + HostValue::from_json(serde_json::json!(-2)), + HostValue::Int(-2) + )); + assert!(matches!( + HostValue::from_json(serde_json::json!(0.5)), + HostValue::Float(f) if f == 0.5 + )); + } +}