I do not at all oppose investigating the use of backpack to make swapping of implementation easier, but the ability to pull it out as an implementation-agnostic interface is merely a convenient side effect of being extremely stringent in acknowledging the unique properties of all of the various algorithms
I feel as though I need to clarify something for the general audience, however, as there appears to be some wariness or misunderstanding of why the typeclasses are being developed:
The typeclass abstractions are still necessary within botan
as a single package, in order to expose multiple algorithms for the same primitive in a type-safe manner without throwing exceptions, or pretending that algorithm variants don’t exist.
Many algorithms have unique knobs and levers, and it is only at the very end, when we process the plaintext, that they act similarly. Digest MD5
is not the same type as Digest (SHA3 512)
, and should not be considered equal, even if they were to contain the same bytes.
Some algorithm families are parameterized by a unique salt / identifier / tweak that is not necessarily bounded in size, and thus there may be an infinite number of instances to write manually. Skein512 512 "foo"
takes two additional parameters compared to MD5
- how are we to provide the additional arguments while providing a uniform interface?
Typeclasses were designed for this; the alternative is passing an algorithm witness around exactly like you might a typeclass implementation dictionary, while having to handle errors when people pass in a parameter that isn’t valid for a given algorithm / operation, which would be normally be handled by the type system via typeclass constraints.
I’d be writing the newtypes anyway for per-algorithm type-safety - take a look! The data families are effectively free and the typeclasses just codify everything that I’m doing anyway.
We have our data family and typeclass - not scary at all :
data family Digest hash
class (Eq (Digest hash), Ord (Digest hash)) => Hash hash where
hash :: ByteString -> Digest hash
We could implement simple concrete types for our algorithm this way, without the typeclass and data families:
newtype MD5Digest = MkMD5Digest { getMD5ByteString :: ByteString }
deriving newtype (Eq, Ord)
md5 :: ByteString -> MD5Digest
md5 = MkMD5Digest . Botan.hash Botan.md5
However, conformance to the typeclasses is effectively free
-- Free
data MD5
-- Free by replacing "MD5Digest" with "instance Digest MD5"
newtype instance Digest MD5 = MkMD5Digest { getMD5ByteString :: ByteString }
deriving newtype (Eq, Ord)
-- Unchanged
md5 :: ByteString -> MD5Digest
md5 = MkMD5Digest . Botan.hash Botan.md5
-- Free
type MD5Digest = Digest MD5
-- Free
instance Hash MD5 where
hash :: ByteString -> Digest MD5
hash = md5
Something with a few type parameters isn’t much harder.
data SHA3 (n :: Nat)
-- Pardon this atrocity
type SHA3Size (n :: Nat) = (KnownNat n, (n == 224 || n == 256 || n == 384 || n == 512) ~ True)
newtype instance Digest (SHA3 n) = MkSHA3Digest { getSHA3ByteString :: ByteString }
deriving newtype (Eq, Ord)
instance (SHA3Size n) => Hash (SHA3 n) where
hash :: ByteString -> Digest (SHA3 n)
hash = sha3
-- The only actual work involves type literals
sha3 :: (SHA3Size n) => ByteString -> Digest (SHA3 n)
sha3 = MkSHA3Digest . Botan.hash h where
n = fromInteger $ natVal $ Proxy @n
h = fromJust $ Botan.sha3
-- We still export user-friendly functions at the end
sha3_512 :: ByteString -> Digest (SHA3 512)
sha3_512 = sha3 @512
I want to stress that these typeclasses aren’t replacing anything, they’re only augmenting. I hope this gives a better idea as to the reasoning behind my approach.