diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 890a096..3da33e8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,6 +48,12 @@ jobs: with: token: ${{ github.token }} + - name: Clippy without default features + uses: actions-rs/clippy-check@v1 + with: + token: ${{ github.token }} + args: --no-default-features + - name: Audit uses: actions-rs/audit-check@v1 with: diff --git a/Cargo.toml b/Cargo.toml index a252548..f308120 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,14 +15,19 @@ name = "tagged-base64" required-features = ["build-cli"] [features] -default = [] +default = ["ark-serialize", "serde"] +ark-serialize = ["dep:ark-serialize"] +serde = ["dep:serde", "tagged-base64-macros/serde"] wasm-debug = ["dep:console_error_panic_hook"] build-cli = ["dep:clap"] [dependencies] -crc-any = { version = "2.4.1", default-features = false } - +ark-serialize = { version = "0.3.0", optional = true, default-features = false, features = ["derive"] } base64 = "0.13.0" +crc-any = { version = "2.4.1", default-features = false } +serde = { version = "1.0", optional = true, features = ["derive"] } +snafu = { version = "0.7", features = ["backtraces"] } +tagged-base64-macros = { path = "macros", default-features = false } # Command line argument processing clap = { version = "4.0", optional = true, features = ["derive"] } @@ -40,9 +45,12 @@ web-sys = { version = "0.3.49", optional = true, features = ["console", "Headers console_error_panic_hook = { version = "0.1.7", optional = true } [dev-dependencies] +ark-std = { version = "0.3.0", default-features = false } +bincode = "1.3" getrandom = { version = "0.2", features = ["js"] } quickcheck = "1.0" quickcheck_macros = "1.0" +serde_json = "1.0" wasm-bindgen-test = { version = "0.3.28" } [profile.release] diff --git a/macros/Cargo.toml b/macros/Cargo.toml new file mode 100644 index 0000000..62c5d42 --- /dev/null +++ b/macros/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "tagged-base64-macros" +description = "Procedural macros associated with tagged-base64" +version = "0.2.0" +authors = ["Espresso Systems "] +edition = "2018" + +[lib] +proc-macro = true + +[features] +default-features = ["serde"] +serde = [] + +[dependencies] +ark-std = { version = "0.3.0", default-features = false } +syn = { version = "1.0", features = ["extra-traits"] } +quote = "1.0" + +[dev-dependencies] +ark-serialize = { version = "0.3.0", default-features = false, features = ["derive"] } +ark-bls12-381 = { version = "0.3.0", default-features = false, features = ["curve"] } +bincode = { version = "1.3.3", default-features = false } +rand_chacha = { version = "0.3.1" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0.61" diff --git a/macros/src/lib.rs b/macros/src/lib.rs new file mode 100644 index 0000000..8b9256f --- /dev/null +++ b/macros/src/lib.rs @@ -0,0 +1,164 @@ +// Copyright (c) 2022 Espresso Systems (espressosys.com) +#![no_std] + +extern crate proc_macro; + +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, AttributeArgs, Item, Meta, NestedMeta}; + +/// Derive serdes for a type which serializes as a binary blob. +/// +/// This macro can be used to easily derive friendly serde implementations for a binary type which +/// implements [CanonicalSerialize](ark_serialize::CanonicalSerialize) and +/// [CanonicalDeserialize](ark_serialize::CanonicalDeserialize). This is useful for cryptographic +/// primitives and other types which do not have a human-readable serialization, but which may be +/// embedded in structs with a human-readable serialization. The serde implementations derived by +/// this macro will serialize the type as bytes for binary encodings and as base 64 for human +/// readable encodings. +/// +/// Specifically, this macro does 4 things when applied to a type definition: +/// * It adds `#[derive(Serialize, Deserialize)]` to the type definition, along with serde +/// attributes to serialize using [TaggedBase64]. +/// * It creates an implementation of [Tagged] for the type using the specified tag. This tag will +/// be used to identify base 64 strings which represent this type in human-readable encodings. +/// * It creates an implementation of `TryFrom` for the type `T`, which is needed to +/// make the `serde(try_from)` attribute work. +/// * It creates implementations of [Display](ark_std::fmt::Display) and +/// [FromStr](ark_std::str::FromStr) using tagged base 64 as a display format. This allows tagged +/// blob types to be conveniently displayed and read to and from user interfaces in a manner +/// consistent with how they are serialized. +/// +/// Usage example: +/// +/// ``` +/// #[macro_use] extern crate tagged_base64_macros; +/// use ark_serialize::*; +/// +/// #[tagged("PRIM")] +/// #[derive(Clone, CanonicalSerialize, CanonicalDeserialize, /* any other derives */)] +/// pub struct CryptoPrim( +/// // This type can only be serialied as an opaque, binary blob using ark_serialize. +/// pub(crate) ark_bls12_381::Fr, +/// ); +/// ``` +/// +/// The type `CryptoPrim` can now be serialized as binary: +/// ``` +/// # use ark_serialize::*; +/// # use ark_std::UniformRand; +/// # use tagged_base64_macros::tagged; +/// # use rand_chacha::{ChaChaRng, rand_core::SeedableRng}; +/// # #[tagged("PRIM")] +/// # #[derive(Clone, CanonicalSerialize, CanonicalDeserialize, /* any other derives */)] +/// # struct CryptoPrim(ark_bls12_381::Fr); +/// # let crypto_prim = CryptoPrim(ark_bls12_381::Fr::rand(&mut ChaChaRng::from_seed([42; 32]))); +/// bincode::serialize(&crypto_prim).unwrap(); +/// ``` +/// or as base64: +/// ``` +/// # use ark_serialize::*; +/// # use ark_std::UniformRand; +/// # use tagged_base64_macros::tagged; +/// # use rand_chacha::{ChaChaRng, rand_core::SeedableRng}; +/// # #[tagged("PRIM")] +/// # #[derive(Clone, CanonicalSerialize, CanonicalDeserialize, /* any other derives */)] +/// # struct CryptoPrim(ark_bls12_381::Fr); +/// # let crypto_prim = CryptoPrim(ark_bls12_381::Fr::rand(&mut ChaChaRng::from_seed([42; 32]))); +/// serde_json::to_string(&crypto_prim).unwrap(); +/// ``` +/// which will produce a tagged base64 string like +/// "PRIM~8oaujwbov8h4eEq7HFpqW6mIXhVbtJGxLUgiKrGpMCoJ". +#[proc_macro_attribute] +pub fn tagged(args: TokenStream, input: TokenStream) -> TokenStream { + let args = parse_macro_input!(args as AttributeArgs); + let input = parse_macro_input!(input as Item); + let (name, generics) = match &input { + Item::Struct(item) => (&item.ident, &item.generics), + Item::Enum(item) => (&item.ident, &item.generics), + _ => panic!("expected struct or enum"), + }; + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + let tag: &dyn quote::ToTokens = match args.as_slice() { + [NestedMeta::Lit(tag)] => tag, + [NestedMeta::Meta(Meta::Path(path))] => path, + x => panic!( + "`tagged` takes one argument, the tag, as a string literal or expression, found {:?}", + x + ), + }; + + #[cfg(feature = "serde")] + let struct_def = quote! { + #[derive(serde::Serialize, serde::Deserialize)] + #[serde(try_from = "tagged_base64::TaggedBase64", into = "tagged_base64::TaggedBase64")] + // Override the inferred bound for Serialize/Deserialize impls. If we're converting to and + // from CanonicalBytes as an intermediate, the impls should work for any generic parameters. + #[serde(bound = "")] + #input + }; + #[cfg(not(feature = "serde"))] + let struct_def = &input; + + let output = quote! { + #struct_def + + impl #impl_generics tagged_base64::Tagged for #name #ty_generics #where_clause { + fn tag() -> ark_std::string::String { + ark_std::string::String::from(#tag) + } + } + + impl #impl_generics core::convert::TryFrom + for #name #ty_generics + #where_clause + { + type Error = tagged_base64::Tb64Error; + fn try_from(t: tagged_base64::TaggedBase64) -> Result { + if t.tag() == <#name #ty_generics>::tag() { + ::deserialize(t.as_ref()) + .map_err(|_| tagged_base64::Tb64Error::InvalidData) + } else { + Err(tagged_base64::Tb64Error::InvalidTag) + } + } + } + + impl #impl_generics core::convert::From<#name #ty_generics> for tagged_base64::TaggedBase64 + #where_clause + { + fn from(x: #name #ty_generics) -> Self { + (&x).into() + } + } + + impl #impl_generics core::convert::From<&#name #ty_generics> for tagged_base64::TaggedBase64 + #where_clause + { + fn from(x: &#name #ty_generics) -> Self { + let mut bytes = ark_std::vec![]; + x.serialize(&mut bytes).unwrap(); + Self::new(&<#name #ty_generics>::tag(), &bytes).unwrap() + } + } + + impl #impl_generics ark_std::fmt::Display for #name #ty_generics #where_clause { + fn fmt(&self, f: &mut ark_std::fmt::Formatter<'_>) -> ark_std::fmt::Result { + ark_std::write!( + f, "{}", + tagged_base64::TaggedBase64::from(self) + ) + } + } + + impl #impl_generics ark_std::str::FromStr for #name #ty_generics #where_clause { + type Err = tagged_base64::Tb64Error; + fn from_str(s: &str) -> Result { + use core::convert::TryFrom; + Self::try_from(tagged_base64::TaggedBase64::from_str(s)?) + .map_err(|_| tagged_base64::Tb64Error::InvalidData) + } + } + }; + output.into() +} diff --git a/src/lib.rs b/src/lib.rs index b0b3e45..77313bc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,15 +41,25 @@ //! well as display and input in a user interface. #![allow(clippy::unused_unit)] +#[cfg(feature = "ark-serialize")] +use ark_serialize::*; use core::fmt; #[cfg(target_arch = "wasm32")] use core::fmt::Display; use core::str::FromStr; use crc_any::CRC; +#[cfg(feature = "serde")] +use serde::{ + de::{Deserialize, Deserializer, Error as DeError}, + ser::{Error as SerError, Serialize, Serializer}, +}; +use snafu::Snafu; #[cfg(target_arch = "wasm32")] use wasm_bindgen::prelude::*; +pub use tagged_base64_macros::tagged; + /// Separator that does not appear in URL-safe base64 encoding and can /// appear in URLs without percent-encoding. pub const TB64_DELIM: char = '~'; @@ -61,12 +71,54 @@ pub const TB64_CONFIG: base64::Config = base64::URL_SAFE_NO_PAD; /// covering the tag and the bytes. #[cfg_attr(target_arch = "wasm32", wasm_bindgen)] #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "ark-serialize", + derive(CanonicalSerialize, CanonicalDeserialize) +)] pub struct TaggedBase64 { tag: String, value: Vec, checksum: u8, } +#[cfg(feature = "serde")] +impl Serialize for TaggedBase64 { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if serializer.is_human_readable() { + // If we are serializing to a human-readable format, be nice and just display the + // tagged base 64 as a string. + Serialize::serialize(&self.to_string(), serializer) + } else { + // For binary formats, convert to bytes (using CanonicalSerialize) and write the bytes. + let mut bytes = vec![]; + CanonicalSerialize::serialize(self, &mut bytes).map_err(S::Error::custom)?; + Serialize::serialize(&bytes, serializer) + } + } +} + +#[cfg(feature = "serde")] +impl<'a> Deserialize<'a> for TaggedBase64 { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'a>, + { + if deserializer.is_human_readable() { + // If we are deserializing a human-readable format, the serializer would have written + // the tagged base 64 as a string, so deserialize a string and then parse it. + Self::from_str(Deserialize::deserialize(deserializer)?).map_err(D::Error::custom) + } else { + // Otherwise, this is a binary format; deserialize bytes and then convert the bytes to + // TaggedBase64 using CanonicalDeserialize. + let bytes = as Deserialize>::deserialize(deserializer)?; + CanonicalDeserialize::deserialize(bytes.as_slice()).map_err(D::Error::custom) + } + } +} + /// JavaScript-compatible wrapper for TaggedBase64 /// /// The primary difference is that JsTaggedBase64 returns errors @@ -77,7 +129,7 @@ pub struct JsTaggedBase64 { tb64: TaggedBase64, } -#[derive(Debug)] +#[derive(Debug, Snafu)] pub enum Tb64Error { /// An invalid character was found in the tag. InvalidTag, @@ -87,38 +139,25 @@ pub enum Tb64Error { MissingChecksum, /// An invalid byte was found while decoding the base64-encoded value. /// The offset and offending byte are provided. - InvalidByte(usize, u8), + #[snafu(display( + "An invalid byte ({:#x}) was found at offset {} while decoding the base64-encoded value.", + byte, + offset + ))] + InvalidByte { offset: usize, byte: u8 }, /// The last non-padding input symbol's encoded 6 bits have /// nonzero bits that will be discarded. This is indicative of /// corrupted or truncated Base64. Unlike InvalidByte, which /// reports symbols that aren't in the alphabet, this error is for /// symbols that are in the alphabet but represent nonsensical /// encodings. - InvalidLastSymbol(usize, u8), + InvalidLastSymbol { offset: usize, byte: u8 }, /// The length of the base64-encoded value is invalid. InvalidLength, /// The checksum was truncated or did not match. InvalidChecksum, -} - -impl fmt::Display for Tb64Error { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match *self { - Tb64Error::InvalidTag => - write!(f, "An invalid character was found in the tag."), - Tb64Error::MissingDelimiter => - write!(f, "Missing delimiter ({}).", TB64_DELIM), - Tb64Error::MissingChecksum => - write!(f, "Missing checksum in value."), - Tb64Error::InvalidByte(offset, byte) => - write!(f, "An invalid byte ({:#0x}) was found at offset {} while decoding the base64-encoded value. The offset and offending byte are provided.", byte, offset), - Tb64Error::InvalidLastSymbol(offset, byte) => write!(f, "The last non-padding input symbol's encoded 6 bits have nonzero bits that will be discarded. This is indicative of corrupted or truncated Base64. Unlike InvalidByte, which reports symbols that aren't in the alphabet, this error is for symbols that are in the alphabet but represent nonsensical encodings. Invalid byte ({:#0x}) at offset {}.", byte, offset), - Tb64Error::InvalidLength => - write!(f, "The length of the base64-encoded value is invalid."), - Tb64Error::InvalidChecksum => - write!(f, "The checksum was truncated or did not match."), - } - } + /// The data did not encode the expected type. + InvalidData, } /// Converts a TaggedBase64 value to a String. @@ -296,15 +335,23 @@ impl TaggedBase64 { /// Wraps the underlying base64 decoder. pub fn decode_raw(value: &str) -> Result, Tb64Error> { base64::decode_config(value, TB64_CONFIG).map_err(|err| match err { - base64::DecodeError::InvalidByte(offset, byte) => Tb64Error::InvalidByte(offset, byte), + base64::DecodeError::InvalidByte(offset, byte) => { + Tb64Error::InvalidByte { offset, byte } + } base64::DecodeError::InvalidLength => Tb64Error::InvalidLength, base64::DecodeError::InvalidLastSymbol(offset, byte) => { - Tb64Error::InvalidLastSymbol(offset, byte) + Tb64Error::InvalidLastSymbol { offset, byte } } }) } } +impl AsRef<[u8]> for TaggedBase64 { + fn as_ref(&self) -> &[u8] { + &self.value + } +} + /// Converts any object that supports the Display trait to a JsValue for /// passing to Javascript. /// @@ -375,3 +422,15 @@ impl JsTaggedBase64 { self.tb64.to_string() } } + +/// Trait for types whose serialization is not human-readable. +/// +/// Such types have a human-readable tag which is used to identify tagged base +/// 64 blobs representing a serialization of that type. +/// +/// Rather than implement this trait manually, it is recommended to use the +/// [macro@tagged] macro to specify a tag for your type. That macro also +/// derives appropriate serde implementations for serializing as an opaque blob. +pub trait Tagged { + fn tag() -> String; +} diff --git a/tests/tests.rs b/tests/tests.rs index 91d5ae9..6215726 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1,8 +1,10 @@ // Copyright © 2022 Translucence Research, Inc. All rights reserved. +use ark_serialize::*; use quickcheck_macros::quickcheck; use base64::{decode_config, encode_config}; +use std::convert::TryInto; use std::str; use tagged_base64::*; @@ -363,16 +365,24 @@ fn test_js_new_error() { #[wasm_bindgen_test] fn wasm_error_to_string() { assert_eq!( - JsValue::from(Tb64Error::InvalidByte(66, 42)), - to_jsvalue("An invalid byte (0x2a) was found at offset 66 while decoding the base64-encoded value. The offset and offending byte are provided.") + JsValue::from(Tb64Error::InvalidByte { offset: 66, byte: 42 }), + to_jsvalue("An invalid byte (0x2a) was found at offset 66 while decoding the base64-encoded value.") ); } #[test] fn test_error_fmt() { assert_eq!( - format!("{}", Tb64Error::InvalidByte(66, 42)), - "An invalid byte (0x2a) was found at offset 66 while decoding the base64-encoded value. The offset and offending byte are provided.".to_string()); + format!( + "{}", + Tb64Error::InvalidByte { + offset: 66, + byte: 42 + } + ), + "An invalid byte (0x2a) was found at offset 66 while decoding the base64-encoded value." + .to_string() + ); } #[test] @@ -387,7 +397,7 @@ fn basic_errors() { let e = TaggedBase64::parse("AAA~A/A").unwrap_err(); println!("{:?}: {}", e, e); - assert!(matches!(e, Tb64Error::InvalidByte(_, _))); + assert!(matches!(e, Tb64Error::InvalidByte { .. })); let e = TaggedBase64::parse("AAA~AAA").unwrap_err(); println!("{:?}: {}", e, e); @@ -403,7 +413,7 @@ fn basic_errors() { let e = TaggedBase64::parse("AAA~AAF").unwrap_err(); println!("{:?}: {}", e, e); - assert!(matches!(e, Tb64Error::InvalidLastSymbol(_, _))); + assert!(matches!(e, Tb64Error::InvalidLastSymbol { .. })); } fn one_bit_corruption(tag: u16, data: (Vec, u8), bit_to_flip: u16) { @@ -427,3 +437,35 @@ fn one_bit_corruption(tag: u16, data: (Vec, u8), bit_to_flip: u16) { fn one_bit_corruption_quickcheck(tag: u16, data: (Vec, u8), bit_to_flip: u16) { one_bit_corruption(tag, data, bit_to_flip); } + +#[tagged("BLOB")] +#[derive(Clone, Debug, PartialEq, Eq, CanonicalSerialize, CanonicalDeserialize)] +struct Blob(Vec); + +#[test] +fn test_tagged() { + let bytes = (0..100).into_iter().collect(); + let b = Blob(bytes); + let t = TaggedBase64::from(&b); + assert!(t.to_string().starts_with("BLOB~")); + assert_eq!(b, t.try_into().unwrap()); +} + +#[test] +fn test_serde_json() { + let bytes = (0..100).into_iter().collect::>(); + let t = TaggedBase64::new("TAG", &bytes).unwrap(); + let s = serde_json::to_string(&t).unwrap(); + assert!(s.starts_with("\"TAG~")); + assert_eq!(t, serde_json::from_str(&s).unwrap()); +} + +#[test] +fn test_serde_bincode() { + let bytes = (0..100).into_iter().collect::>(); + let t = TaggedBase64::new("TAG", &bytes).unwrap(); + assert_eq!( + t, + bincode::deserialize(&bincode::serialize(&t).unwrap()).unwrap() + ); +}