packages feed

age (empty) → 0.0.1.0

raw patch · 178 files changed

+6172/−0 lines, 178 filesdep +agedep +attoparsecdep +basesetup-changedbinary-added

Dependencies added: age, attoparsec, base, base16, base64, bech32, binary, bytestring, conduit, conduit-extra, crypton, directory, hedgehog, memory, mmorph, mono-traversable, mtl, text, transformers

Files

+ CHANGELOG.md view
@@ -0,0 +1,5 @@+# ChangeLog for age++## 0.0.1.0 -- 2025-09-15++- Initial pre-release.
+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2025 intricate++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ README.md view
@@ -0,0 +1,8 @@+# age-haskell++A Haskell implementation of [age](https://age-encryption.org/v1).++## What is age?++age is a modern file encryption format with multiple pluggable recipients, and+seekable streaming encryption.
+ Setup.hs view
@@ -0,0 +1,3 @@+import Distribution.Simple++main = defaultMain
+ age.cabal view
@@ -0,0 +1,146 @@+cabal-version:       3.4+name:                age+version:             0.0.1.0+synopsis:            Actually Good Encryption+description:+  Haskell implementation of+  [age (Actually Good Encryption)](https://age-encryption.org/v1).++  age is a modern file encryption format with multiple pluggable recipients,+  and seekable streaming encryption.++  The recommended entry point for this library is "Crypto.Age".++  __⚠️ Warning: This is an experimental pre-release which is still under development. As a result, the API is unstable and subject to breaking changes.__+author:              Luke Nadur+maintainer:          Luke Nadur+license:             MIT+license-file:        LICENSE+category:            Cryptography+homepage:            https://github.com/intricate/age-haskell+bug-reports:         https://github.com/intricate/age-haskell/issues+build-type:          Simple+tested-with:         GHC == 9.8.2, GHC == 9.4.8+extra-source-files:+  README.md+  test/golden/**/*.bin+  test/golden/**/*.txt+  test/test-vectors/*.test+extra-doc-files:     CHANGELOG.md++library+  default-language:    Haskell2010+  hs-source-dirs:      src+  default-extensions:  DerivingStrategies+                       GeneralizedNewtypeDeriving+                       NamedFieldPuns+                       NoImplicitPrelude+                       OverloadedStrings++  ghc-options:         -Wall+                       -Wcompat+                       -Wincomplete-record-updates+                       -Wincomplete-uni-patterns+                       -Wpartial-fields+                       -Wredundant-constraints+                       -Wunused-packages+                       -Wno-unticked-promoted-constructors++  exposed-modules:     Crypto.Age+                       Crypto.Age.Armor+                       Crypto.Age.Buffered+                       Crypto.Age.Conduit+                       Crypto.Age.Header+                       Crypto.Age.Identity+                       Crypto.Age.Identity.Stanza+                       Crypto.Age.Key+                       Crypto.Age.Payload.Ciphertext+                       Crypto.Age.Payload.Counter+                       Crypto.Age.Payload.Plaintext+                       Crypto.Age.Recipient+                       Crypto.Age.Recipient.Stanza+                       Crypto.Age.Scrypt++  other-modules:       Data.Attoparsec.ByteString.Base64+                       Data.Attoparsec.ByteString.Extra+                       Data.Binary.Put.Integer+                       Data.ByteString.Base64.Extra+                       Data.ByteString.Extra+                       Data.Conduit.Base64+                       Data.List.Compat++  build-depends:       base >= 4.17 && < 4.22+                     , attoparsec >= 0.14.1 && < 0.15+                     , base64 >= 0.4 && < 1.1+                     , bech32 >= 1.1.1 && < 1.2+                     , binary >= 0.8.9 && < 0.9+                     , bytestring >= 0.11.1 && < 0.13+                     , conduit >= 1.3.5 && < 1.4+                     , conduit-extra >= 1.3.0 && < 1.4+                     , crypton >= 0.32 && < 1.1+                     , memory >= 0.17.0 && < 0.19+                     , mono-traversable >= 1.0.15.3 && < 1.1+                     , mtl >= 2.2.2 && < 2.4+                     , text >= 2.0 && < 2.2+                     , transformers >= 0.5.6.2 && < 0.7++test-suite age-test+  default-language:    Haskell2010+  type:                exitcode-stdio-1.0+  main-is:             Main.hs+  hs-source-dirs:      test+  default-extensions:  DerivingStrategies+                       GeneralizedNewtypeDeriving+                       NamedFieldPuns+                       NoImplicitPrelude+                       OverloadedStrings++  ghc-options:         -Wall+                       -Wcompat+                       -Wredundant-constraints+                       -Wincomplete-patterns+                       -Wincomplete-record-updates+                       -Wincomplete-uni-patterns+                       -Wunused-imports+                       -Wunused-packages+                       -Wno-unticked-promoted-constructors++  other-modules:       Test.Crypto.Age.Armor+                       Test.Crypto.Age.Conduit+                       Test.Crypto.Age.Header+                       Test.Crypto.Age.Header.Gen+                       Test.Crypto.Age.Identity+                       Test.Crypto.Age.Identity.Gen+                       Test.Crypto.Age.Key+                       Test.Crypto.Age.Key.Gen+                       Test.Crypto.Age.Key.Render+                       Test.Crypto.Age.Payload.Counter.Gen+                       Test.Crypto.Age.Payload.Plaintext.Gen+                       Test.Crypto.Age.Recipient+                       Test.Crypto.Age.Recipient.Gen+                       Test.Crypto.Age.Scrypt+                       Test.Crypto.Age.Scrypt.Gen+                       Test.Crypto.Age.TestVector+                       Test.Crypto.Age.TestVector.Header+                       Test.Crypto.Age.TestVector.Property+                       Test.Gen+                       Test.Golden++  build-depends:       base >= 4.17 && < 4.22+                     , age+                     , attoparsec+                     , base16+                     , bytestring+                     , conduit+                     , conduit-extra+                     , crypton+                     , directory+                     , hedgehog+                     , memory+                     , mmorph+                     , mtl+                     , text++source-repository head+  type:     git+  location: git://github.com/intricate/age-haskell.git
+ src/Crypto/Age.hs view
@@ -0,0 +1,100 @@+-- | This module is the recommended entry point for this library.+module Crypto.Age+  ( -- * Encryption+    -- ** Buffered+    encrypt+  , encryptLazy+    -- ** Streaming+  , conduitEncrypt+  , conduitEncryptEither+  , sinkEncrypt+  , sinkEncryptEither+    -- ** Errors+  , EncryptError (..)++    -- * Decryption+    -- ** Buffered+  , decrypt+  , decryptLazy+    -- ** Streaming+  , conduitDecrypt+  , conduitDecryptEither+  , sinkDecrypt+  , sinkDecryptEither+    -- ** Errors+  , DecryptError (..)++    -- * ASCII armor+    -- ** Encoding+  , ArmorError (..)+  , conduitArmor+    -- ** Decoding+  , UnarmorError (..)+  , conduitUnarmor++    -- * Identity+  , Identity (..)+    -- ** @scrypt@+  , ScryptIdentity (..)+    -- ** X25519+  , X25519Identity (..)+    -- *** Construction+  , generateX25519Identity+  , toX25519Recipient+    -- *** Encoding+  , bytesToX25519Identity+  , x25519IdentityToBytes+  , encodeX25519Identity+  , decodeX25519Identity+  , DecodeX25519IdentityError (..)++    -- * Recipient+  , Recipients (..)+    -- ** @scrypt@+  , ScryptRecipient (..)+    -- ** X25519+  , X25519Recipient (..)+  , bytesToX25519Recipient+  , x25519RecipientToBytes+  , encodeX25519Recipient+  , decodeX25519Recipient+  , DecodeX25519RecipientError (..)+  ) where++import Crypto.Age.Armor+  ( ArmorError (..), UnarmorError (..), conduitArmor, conduitUnarmor )+import Crypto.Age.Buffered ( decrypt, decryptLazy, encrypt, encryptLazy )+import Crypto.Age.Conduit+  ( DecryptError (..)+  , EncryptError (..)+  , conduitDecrypt+  , conduitDecryptEither+  , conduitEncrypt+  , conduitEncryptEither+  , sinkDecrypt+  , sinkDecryptEither+  , sinkEncrypt+  , sinkEncryptEither+  )+import Crypto.Age.Identity+  ( DecodeX25519IdentityError (..)+  , Identity (..)+  , ScryptIdentity (..)+  , X25519Identity (..)+  , bytesToX25519Identity+  , decodeX25519Identity+  , encodeX25519Identity+  , generateX25519Identity+  , toX25519Recipient+  , x25519IdentityToBytes+  )+import Crypto.Age.Recipient+  ( DecodeX25519RecipientError (..)+  , Recipients (..)+  , ScryptRecipient (..)+  , X25519Recipient (..)+  , bytesToX25519Recipient+  , decodeX25519Recipient+  , encodeX25519Recipient+  , x25519RecipientToBytes+  )
+ src/Crypto/Age/Armor.hs view
@@ -0,0 +1,230 @@+{-# LANGUAGE LambdaCase #-}++-- | age+-- [ASCII armor](https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#ascii-armor)+-- encoding and decoding.+module Crypto.Age.Armor+  ( -- * Encoding+    ArmorError (..)+  , conduitArmor+    -- * Decoding+  , UnarmorError (..)+  , conduitUnarmor+  ) where++import Control.Applicative ( Alternative (..), optional )+import Control.Monad ( void )+import Control.Monad.Except ( ExceptT, throwError, withExceptT )+import Control.Monad.Trans.Class ( MonadTrans (lift) )+import Data.Attoparsec.ByteString ( Parser, count, endOfInput, string )+import Data.Attoparsec.ByteString.Base64 ( takeNBase64Chars )+import Data.Attoparsec.ByteString.Char8 ( char8, endOfLine, skipSpace )+import Data.Attoparsec.ByteString.Extra ( countMN )+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import Data.Conduit ( ConduitT, await, leftover, transPipe, yield, (.|) )+import Data.Conduit.Attoparsec ( ParseError, sinkParserEither )+import Data.Conduit.Base64 ( conduitDecodeBase64, conduitEncodeBase64 )+import qualified Data.Conduit.Combinators as C+import Data.Text ( Text )+import Data.Word ( Word8 )+import Prelude++label :: ByteString+label = "AGE ENCRYPTED FILE"++lineBegin :: ByteString+lineBegin = "-----BEGIN " <> label <> "-----"++lineEnd :: ByteString+lineEnd = "-----END " <> label <> "-----"++-------------------------------------------------------------------------------+-- Encoding+-------------------------------------------------------------------------------++-- | Error+-- \"[armoring](https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#ascii-armor)\"+-- an age file.+data ArmorError+  = -- | No data was provided to be armored (i.e. end of input was reached+    -- without consuming any bytes).+    ArmorNoDataError+  deriving stock (Show, Eq)++-- | Stream and+-- \"[armor](https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#ascii-armor)\"+-- an age file.+conduitArmor :: Monad m => ConduitT ByteString ByteString (ExceptT ArmorError m) ()+conduitArmor = await >>= \case+  Nothing -> lift (throwError ArmorNoDataError)+  Just x+    | BS.null x ->+        -- An empty 'ByteString' was consumed from upstream, so try again.+        conduitArmor+    | otherwise -> do+        leftover x+        yield (lineBegin <> "\n")+        conduitEncodeBase64+          .| C.chunksOfE 64+          .| C.intersperse "\n"+        yield ("\n" <> lineEnd <> "\n")++-------------------------------------------------------------------------------+-- Decoding+-------------------------------------------------------------------------------++-- | @eol@ rule from+-- [RFC 7468](https://www.rfc-editor.org/rfc/rfc7468.html#section-3).+eolParser :: Parser ()+eolParser = endOfLine <|> void (char8 '\r')++lineBeginParser :: Parser ()+lineBeginParser = do+  void $ string lineBegin+  eolParser++lineEndParser :: Parser ()+lineEndParser = void $ string lineEnd++-- | @4base64char@ rule from+-- [RFC 7468](https://www.rfc-editor.org/rfc/rfc7468.html#section-3).+fourBase64CharParser :: Parser ByteString+fourBase64CharParser = takeNBase64Chars 4++-- | @base64pad@ rule from+-- [RFC 7468](https://www.rfc-editor.org/rfc/rfc7468.html#section-3).+base64PadParser :: Parser Word8+base64PadParser = char8 '='++-- | Parsed base64-encoded line.+data Base64Line+  = -- | Unpadded base64-encoded line of 64 characters.+    Base64FullUnpaddedLine !ByteString+  | -- | Padded base64-encoded line of 64 characters.+    Base64FullPaddedLine !ByteString+  | -- | Base64-encoded line (either padded or unpadded) of less than 64+    -- characters.+    Base64ShortLine !ByteString++-- | @base64fullline@ rule from+-- [RFC 7468](https://www.rfc-editor.org/rfc/rfc7468.html#section-3).+base64FullUnpaddedLineParser :: Parser Base64Line+base64FullUnpaddedLineParser = Base64FullUnpaddedLine <$> (takeNBase64Chars 64 <* eolParser)++-- | Variant of the @strictbase64finl@ rule from+-- [RFC 7468](https://www.rfc-editor.org/rfc/rfc7468.html#section-3) which only+-- parses a base64-encoded and padded line of 64 characters.+base64FullPaddedLineParser :: Parser Base64Line+base64FullPaddedLineParser =  do+  b64Chars <- count 15 fourBase64CharParser+  remainingB64Chars <- remainingB64CharsParser+  eolParser+  pure $ Base64FullPaddedLine (BS.concat b64Chars <> remainingB64Chars)+  where+    remainingB64CharsParser :: Parser ByteString+    remainingB64CharsParser =+      (mappend <$> takeNBase64Chars 3 <*> (BS.singleton <$> base64PadParser))+        <|> (mappend <$> takeNBase64Chars 2 <*> (BS.pack <$> count 2 base64PadParser))++-- | Variant of the @strictbase64finl@ rule from+-- [RFC 7468](https://www.rfc-editor.org/rfc/rfc7468.html#section-3) which only+-- parses a base64-encoded line (either padded or not) that is less than 64+-- characters.+base64ShortLineParser :: Parser Base64Line+base64ShortLineParser = do+  b64Chars <- countMN 0 14 fourBase64CharParser+  remainingB64Chars <-+    if length b64Chars == 0+      then Just <$> remainingB64CharsParser+      else optional remainingB64CharsParser+  eolParser+  pure $ Base64ShortLine (BS.concat b64Chars <> maybe BS.empty id remainingB64Chars)+  where+    remainingB64CharsParser :: Parser ByteString+    remainingB64CharsParser =+      takeNBase64Chars 4+        <|> (mappend <$> takeNBase64Chars 3 <*> (BS.singleton <$> base64PadParser))+        <|> (mappend <$> takeNBase64Chars 2 <*> (BS.pack <$> count 2 base64PadParser))++sinkParseLineBegin :: Monad m => ConduitT ByteString o m (Either ParseError ())+sinkParseLineBegin = sinkParserEither lineBeginParser++-- | Internal helper type for 'conduitParseBase64UntilLineEnd'.+data ParsedLine+  = ParsedLineBase64 !Base64Line+  | ParsedLineEnd++conduitParseBase64UntilLineEnd :: Monad m => ConduitT ByteString ByteString (ExceptT UnarmorError m) ()+conduitParseBase64UntilLineEnd = await >>= \case+  Nothing ->+    -- End of input was reached, but the ending line was not parsed.+    lift (throwError UnarmorNoLineEndError)+  Just x -> do+    leftover x+    res <-+      sinkParserEither $+        (ParsedLineBase64 <$> base64FullUnpaddedLineParser)+          <|> (ParsedLineBase64 <$> base64FullPaddedLineParser)+          <|> (ParsedLineBase64 <$> base64ShortLineParser)+          <|> (lineEndIgnoreWhitespaceParser *> pure ParsedLineEnd)+    case res of+      Left err -> lift (throwError $ UnarmorParseError err)+      Right (ParsedLineBase64 parsedB64Line) ->+        case parsedB64Line of+          Base64FullUnpaddedLine b64Line -> do+            yield b64Line+            conduitParseBase64UntilLineEnd+          Base64FullPaddedLine b64Line -> do+            -- Parsed a padded line, so we should be at the end line now.+            yield b64Line+            lineEndRes <- sinkParserEither lineEndIgnoreWhitespaceParser+            case lineEndRes of+              Left err -> lift (throwError $ UnarmorParseError err)+              Right () -> pure ()+          Base64ShortLine b64Line -> do+            -- Parsed a short line, so we should be at the end line now.+            yield b64Line+            lineEndRes <- sinkParserEither lineEndIgnoreWhitespaceParser+            case lineEndRes of+              Left err -> lift (throwError $ UnarmorParseError err)+              Right () -> pure ()+      Right ParsedLineEnd -> pure ()+  where+    lineEndIgnoreWhitespaceParser :: Parser ()+    lineEndIgnoreWhitespaceParser = lineEndParser >> skipSpace >> endOfInput++-- | Error+-- \"un-[armoring](https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#ascii-armor)\"+-- an age file.+data UnarmorError+  = -- | End of input was reached immediately after parsing the beginning line.+    UnarmorNoDataAfterLineBeginError+  | -- | End of input was reached before the ending line was parsed.+    UnarmorNoLineEndError+  | -- | Error parsing the ASCII-armored age file.+    UnarmorParseError !ParseError+  | -- | Error base64 decoding the encapsulated text portion of the+    -- ASCII-armored age file.+    UnarmorDecodeBase64Error !Text+  deriving stock (Show)++-- | Stream and+-- \"un-[armor](https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#ascii-armor)\"+-- an age file.+conduitUnarmor :: Monad m => ConduitT ByteString ByteString (ExceptT UnarmorError m) ()+conduitUnarmor = do+  -- Ignore leading whitespace+  void (sinkParserEither skipSpace)++  sinkParseLineBegin >>= \case+    Left err -> lift (throwError $ UnarmorParseError err)+    Right () -> pure ()++  -- Ensure that we try to parse at least one more line.+  await >>= \case+    Nothing -> lift (throwError UnarmorNoDataAfterLineBeginError)+    Just x -> do+      leftover x+      conduitParseBase64UntilLineEnd+        .| transPipe (withExceptT UnarmorDecodeBase64Error) conduitDecodeBase64
+ src/Crypto/Age/Buffered.hs view
@@ -0,0 +1,77 @@+{-# LANGUAGE LambdaCase #-}++-- | Buffered encryption and decryption of age files.+module Crypto.Age.Buffered+  ( -- * Encryption+    encrypt+  , encryptLazy++    -- * Decryption+  , decrypt+  , decryptLazy+  ) where++import Control.Monad.Except ( ExceptT (..) )+import Crypto.Age.Conduit+  ( DecryptError+  , EncryptError+  , conduitDecryptEither+  , conduitEncrypt+  , sinkDecryptEither+  , sinkEncrypt+  )+import Crypto.Age.Identity ( Identity )+import Crypto.Age.Recipient ( Recipients )+import qualified Data.ByteString as BS+import qualified Data.ByteString.Lazy as LBS+import Data.Conduit ( ConduitT, await, runConduit, runConduitPure, yield, (.|) )+import qualified Data.Conduit.Combinators as C+import Data.List.NonEmpty ( NonEmpty )+import Data.Sequences ( LazySequence )+import Prelude++-------------------------------------------------------------------------------+-- Encryption+-------------------------------------------------------------------------------++-- | Encrypt a strict 'BS.ByteString'.+encrypt :: Recipients -> BS.ByteString -> ExceptT EncryptError IO BS.ByteString+encrypt recipients plaintext =+  runConduit $+    yield plaintext .| sinkEncrypt recipients++-- | Encrypt a lazy 'LBS.ByteString'.+encryptLazy :: Recipients -> LBS.ByteString -> ExceptT EncryptError IO LBS.ByteString+encryptLazy recipients plaintext =+  runConduit $+    C.sourceLazy plaintext .| conduitEncrypt recipients .| C.sinkLazy++-------------------------------------------------------------------------------+-- Decryption+-------------------------------------------------------------------------------++-- | Decrypt a strict 'BS.ByteString'.+decrypt :: NonEmpty Identity -> BS.ByteString -> Either DecryptError BS.ByteString+decrypt identities ciphertext = runConduitPure $ yield ciphertext .| sinkDecryptEither identities++-- | Decrypt a lazy 'LBS.ByteString'.+decryptLazy :: NonEmpty Identity -> LBS.ByteString -> Either DecryptError LBS.ByteString+decryptLazy identities ciphertext =+  runConduitPure $+    C.sourceLazy ciphertext .| conduitDecryptEither identities .| sinkLazyEither++-------------------------------------------------------------------------------+-- Helpers+-------------------------------------------------------------------------------++sinkLazyEither ::+  (Monad m, LazySequence lazy strict) =>+  ConduitT (Either err strict) o m (Either err lazy)+sinkLazyEither = go mempty+  where+    go acc = await >>= \case+      Nothing -> pure (Right acc)+      Just (Left err) -> pure (Left err)+      Just (Right bs) -> do+        lbs <- yield bs .| C.sinkLazy+        go (acc <> lbs)
+ src/Crypto/Age/Conduit.hs view
@@ -0,0 +1,706 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE LambdaCase #-}++-- | Streaming encryption and decryption of age files.+module Crypto.Age.Conduit+  ( -- * Encryption+    EncryptError (..)+  , EncryptPayloadError (..)+  , conduitEncrypt+  , conduitEncryptEither+  , conduitEncryptEitherPure+  , sinkEncrypt+  , sinkEncryptEither+    -- ** Buffered+  , encryptPayloadChunk+    -- ** Parameters+  , RecipientEncryptionParams (..)+  , mkRecipientEncryptionParams++    -- * Decryption+  , DecryptError (..)+  , DecryptPayloadError (..)+  , DecryptPayloadChunkError (..)+  , conduitDecrypt+  , conduitDecryptEither+  , sinkDecrypt+  , sinkDecryptEither+    -- ** Buffered+  , decryptPayloadChunk+  ) where++import Control.Monad ( when )+import Control.Monad.Except ( ExceptT, throwError )+import Control.Monad.IO.Class ( MonadIO (liftIO) )+import Control.Monad.Trans.Class ( MonadTrans (lift) )+import Crypto.Age.Header+  ( Header (..)+  , HeaderMac+  , Stanza (..)+  , computeHeaderMac+  , headerBuilder+  , headerParser+  )+import Crypto.Age.Identity+  ( Identity, X25519Identity (..), generateX25519Identity )+import Crypto.Age.Identity.Stanza+  ( UnwrapStanzaError, unwrapStanzasWithIdentities )+import Crypto.Age.Key+  ( FileKey+  , PayloadKey+  , PayloadKeyNonce+  , generateFileKey+  , generatePayloadKeyNonce+  , mkPayloadKey+  , payloadKeyNonceBuilder+  , payloadKeyNonceParser+  , payloadKeyToBytes+  )+import Crypto.Age.Payload.Ciphertext+  ( CiphertextPayloadChunk (..)+  , FinalCiphertextPayloadChunk (..)+  , authenticationTagSize+  , ciphertextPayloadChunkParser+  , ciphertextPayloadChunkToBytes+  , mkFinalCiphertextPayloadChunk+  , mkNormalCiphertextPayloadChunk+  )+import Crypto.Age.Payload.Counter+  ( IsFinalChunk (..)+  , PayloadChunkCounter+  , incrementPayloadChunkCounter+  , maxPayloadChunkCounter+  , toChaCha20Poly1305Nonce+  , zeroPayloadChunkCounter+  )+import Crypto.Age.Payload.Plaintext+  ( PlaintextPayloadChunk (..)+  , mkFinalPlaintextPayloadChunk+  , mkNormalPlaintextPayloadChunk+  , plaintextPayloadChunkParser+  , plaintextPayloadChunkToBytes+  )+import Crypto.Age.Recipient+  ( Recipients (..), ScryptRecipient (..), X25519Recipient )+import Crypto.Age.Recipient.Stanza+  ( WrapX25519StanzaFileKeyError+  , fromScryptRecipientStanza+  , fromX25519RecipientStanza+  , scryptStanzaTag+  , wrapFileKeyForScryptRecipient+  , wrapFileKeyForX25519Recipient+  )+import qualified Crypto.Cipher.ChaChaPoly1305 as ChaCha20Poly1305+import qualified Crypto.Error as Crypto+import qualified Crypto.MAC.Poly1305 as Poly1305+import Data.Bifunctor ( bimap, first, second )+import qualified Data.ByteArray as BA+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import qualified Data.ByteString.Builder as Builder+import Data.Conduit ( ConduitT, await, awaitForever, leftover, yield, (.|) )+import Data.Conduit.Attoparsec+  ( ParseError, conduitParserEither, sinkParserEither )+import qualified Data.Conduit.Combinators as C+import Data.Conduit.Lift ( exceptC )+import Data.Foldable ( find )+import Data.List.NonEmpty ( NonEmpty )+import qualified Data.List.NonEmpty as NE+import Data.Maybe ( fromMaybe, isJust )+import Prelude++-- | Send an incrementing 'PayloadChunkCounter' downstream alongside each+-- upstream value.+--+-- TODO: consider defining this as a source and using 'mergeSource' instead.+--+-- TODO: consider 'throwM'ing an 'Exception' instead of calling 'error' in the+-- event that the counter would exceed 'maxPayloadChunkCounter'.+conduitIncludeCounter :: Monad m => ConduitT i (PayloadChunkCounter, i) m ()+conduitIncludeCounter = await >>= \case+  Nothing -> pure ()+  Just firstVal ->+    C.scanl+     (\(accCounter, _) x -> (fromMaybe err (incrementPayloadChunkCounter accCounter), x))+     (zeroPayloadChunkCounter, firstVal)+  where+    err :: a+    err = error $+      "conduitIncludeCounter: tried to increment counter over "+        <> show maxPayloadChunkCounter++-------------------------------------------------------------------------------+-- Encryption+-------------------------------------------------------------------------------++-- | Parse a plaintext stream of bytes into 64 KiB chunks.+conduitParsePlaintextPayloadChunk ::+  Monad m =>+  ConduitT ByteString (Either ParseError PlaintextPayloadChunk) m ()+conduitParsePlaintextPayloadChunk =+  conduitParserEither plaintextPayloadChunkParser+    .| C.map (second snd)++-- | Encrypt a chunk of an age file+-- [payload](https://github.com/C2SP/C2SP/blob/91935d7157cb3860351ffebbad1e6f6153e8efc8/age.md#payload).+encryptPayloadChunk ::+  -- | Payload key.+  PayloadKey ->+  -- | Payload chunk counter (used in constructing the @ChaCha20-Poly1305@+  -- nonce).+  PayloadChunkCounter ->+  -- | Payload chunk to be encrypted.+  PlaintextPayloadChunk ->+  CiphertextPayloadChunk+encryptPayloadChunk payloadKey counter chunk = do+  let isFinalChunk :: IsFinalChunk+      isFinalChunk =+        case chunk of+          PlaintextPayloadChunkNormal _ -> IsNotFinalChunk+          PlaintextPayloadChunkFinal _ -> IsFinalChunk++      plaintext :: ByteString+      plaintext = plaintextPayloadChunkToBytes chunk++      nonce :: ChaCha20Poly1305.Nonce+      nonce = toChaCha20Poly1305Nonce isFinalChunk counter++      st :: ChaCha20Poly1305.State+      st = Crypto.throwCryptoError $ ChaCha20Poly1305.initialize (payloadKeyToBytes payloadKey) nonce++      ciphertext :: ByteString+      st2 :: ChaCha20Poly1305.State+      (ciphertext, st2) = ChaCha20Poly1305.encrypt plaintext st++      authTag :: Poly1305.Auth+      authTag = ChaCha20Poly1305.finalize st2++      ciphertextAndTag :: ByteString+      ciphertextAndTag = ciphertext <> BA.convert authTag++  case isFinalChunk of+    IsNotFinalChunk ->+      case mkNormalCiphertextPayloadChunk ciphertextAndTag of+        Nothing -> error "impossible: could not construct NormalCiphertextPayloadChunk chunk from encrypted NormalPlaintextPayloadChunk"+        Just c -> CiphertextPayloadChunkNormal c+    IsFinalChunk ->+      case mkFinalCiphertextPayloadChunk ciphertextAndTag of+        Nothing -> error "impossible: could not construct FinalCiphertextPayloadChunk chunk from encrypted FinalPlaintextPayloadChunk"+        Just c -> CiphertextPayloadChunkFinal c++-- | Error encrypting an age file payload.+data EncryptPayloadError+  = -- | Error parsing a plaintext payload chunk.+    EncryptPayloadPlaintextPayloadChunkParseError !ParseError+  deriving stock (Show)++-- | Stream and encrypt an age file+-- [payload](https://github.com/C2SP/C2SP/blob/91935d7157cb3860351ffebbad1e6f6153e8efc8/age.md#payload).+conduitEncryptPayload ::+  Monad m =>+  FileKey ->+  PayloadKeyNonce ->+  ConduitT ByteString (Either EncryptPayloadError ByteString) m ()+conduitEncryptPayload fileKey payloadKeyNonce = do+  let payloadKey :: PayloadKey+      payloadKey = mkPayloadKey payloadKeyNonce fileKey++  -- Push the encoded payload key nonce downstream.+  yield (Right . BS.toStrict . Builder.toLazyByteString $ payloadKeyNonceBuilder payloadKeyNonce)++  -- Consume and encrypt the plaintext.+  --+  -- Ensure that we try to encrypt at least one plaintext chunk. This prevents+  -- us from creating an invalid age file with no ciphertext chunks.+  --+  -- > Streaming decryption MUST signal an error if the end of file is+  -- > reached without successfully decrypting a final chunk.+  --+  -- https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#payload+  await >>= \case+    Nothing ->+      -- There are no upstream values to encrypt, so let's provide an empty+      -- byte string to be encrypted.+      --+      -- That way, we'll create an age file with a single ciphertext payload+      -- chunk.+      yield BS.empty+        .| conduitParseAndEncryptChunk payloadKey+    Just x -> do+      leftover x+      conduitParseAndEncryptChunk payloadKey++  where+    conduitEncryptChunk ::+      Monad m =>+      PayloadKey ->+      ConduitT (PayloadChunkCounter, Either ParseError PlaintextPayloadChunk) (Either EncryptPayloadError CiphertextPayloadChunk) m ()+    conduitEncryptChunk payloadKey = go+      where+        go = await >>= \case+          Nothing -> pure ()+          Just (_, Left err) -> yield (Left $ EncryptPayloadPlaintextPayloadChunkParseError err)+          Just (counter, Right chunk) -> do+            yield (Right $ encryptPayloadChunk payloadKey counter chunk)+            go++    conduitParseAndEncryptChunk ::+      Monad m =>+      PayloadKey ->+      ConduitT ByteString (Either EncryptPayloadError ByteString) m ()+    conduitParseAndEncryptChunk payloadKey =+      conduitParsePlaintextPayloadChunk+        .| conduitIncludeCounter+        .| conduitEncryptChunk payloadKey+        .| C.map (second ciphertextPayloadChunkToBytes)++-- | Recipient-specific encryption parameters.+data RecipientEncryptionParams+  = RecipientEncryptionParamsScrypt !ScryptRecipient+  | RecipientEncryptionParamsX25519 !(NonEmpty (X25519Recipient, X25519Identity))+  deriving stock (Show, Eq)++-- | Construct 'RecipientEncryptionParams' for the provided 'Recipients'.+mkRecipientEncryptionParams :: Recipients -> IO RecipientEncryptionParams+mkRecipientEncryptionParams = \case+  RecipientsScrypt r -> pure (RecipientEncryptionParamsScrypt r)+  RecipientsX25519 rs ->+    RecipientEncryptionParamsX25519+      <$> mapM (\r -> (,) r <$> generateX25519Identity) rs++-- | Error encrypting an age file.+data EncryptError+  = -- | Error wrapping a file key in an X25519 recipient stanza.+    EncryptWrapX25519StanzaFileKeyError !WrapX25519StanzaFileKeyError+  | -- | Error encrypting an age file payload.+    EncryptEncryptPayloadError !EncryptPayloadError+  deriving stock (Show)++-- | Pure variant of 'conduitEncryptEither'.+--+-- For typical usage, please use 'conduitEncryptEither'.+conduitEncryptEitherPure ::+  Monad m =>+  -- | Recipient-specific encryption parameters.+  --+  -- It is recommended to construct this using 'mkRecipientEncryptionParams'.+  RecipientEncryptionParams ->+  -- | Symmetric file key.+  --+  -- It is recommended to generate this from the operating system's CSPRNG+  -- using 'generateFileKey'.+  FileKey ->+  -- | Payload key nonce.+  --+  -- It is recommended to generate this from the operating system's CSPRNG+  -- using 'generatePayloadKeyNonce'.+  PayloadKeyNonce ->+  ConduitT ByteString (Either EncryptError ByteString) m ()+conduitEncryptEitherPure recipientParams fileKey payloadKeyNonce = do+  let stanzasRes :: Either WrapX25519StanzaFileKeyError (NonEmpty Stanza)+      stanzasRes =+        case recipientParams of+          RecipientEncryptionParamsScrypt r ->+            Right . NE.singleton $ fromScryptRecipientStanza (wrapFileKeyForScryptRecipient r fileKey)+          RecipientEncryptionParamsX25519 ris ->+            mapM+              (\(r, i) -> fromX25519RecipientStanza <$> wrapFileKeyForX25519Recipient r i fileKey)+              ris+  case stanzasRes of+    Left err -> yield (Left $ EncryptWrapX25519StanzaFileKeyError err)+    Right stanzas -> do+      let headerMac :: HeaderMac+          headerMac = computeHeaderMac fileKey stanzas++          header :: Header+          header =+            Header+              { hStanzas = stanzas+              , hMac = headerMac+              }++      -- Encode the header and push the bytes downstream+      yield (Right . BS.toStrict . Builder.toLazyByteString $ headerBuilder header)++      -- Consume and encrypt plaintext chunks.+      conduitEncryptPayload fileKey payloadKeyNonce+        .| C.map (first EncryptEncryptPayloadError)++-- | Stream and age encrypt a byte string.+--+-- Errors are returned in the stream. For a variant that only returns errors+-- after the pipeline is run, see 'conduitEncrypt'.+conduitEncryptEither ::+  MonadIO m =>+  Recipients ->+  ConduitT ByteString (Either EncryptError ByteString) m ()+conduitEncryptEither recipients = do+  recipientParams <- liftIO (mkRecipientEncryptionParams recipients)+  fileKey <- liftIO generateFileKey+  payloadKeyNonce <- liftIO generatePayloadKeyNonce+  conduitEncryptEitherPure recipientParams fileKey payloadKeyNonce++-- | Stream and age encrypt a byte string.+--+-- Errors are returned after the pipeline is run. For a variant that includes+-- errors in the stream, see 'conduitEncryptEither'.+conduitEncrypt ::+  MonadIO m =>+  Recipients ->+  ConduitT ByteString ByteString (ExceptT EncryptError m) ()+conduitEncrypt recipients =+  conduitEncryptEither recipients+    .| go+  where+    go ::+      Monad m =>+      ConduitT (Either EncryptError ByteString) ByteString (ExceptT EncryptError m) ()+    go = awaitForever $ \case+      Left err -> lift (throwError err)+      Right bs -> yield bs++-- | Stream and age encrypt a byte string.+--+-- Errors are returned in the stream. For a variant that only returns errors+-- after the pipeline is run, see 'sinkEncrypt'.+sinkEncryptEither ::+  MonadIO m =>+  Recipients ->+  ConduitT ByteString o m (Either EncryptError ByteString)+sinkEncryptEither recipients =+  conduitEncryptEither recipients+    .| go mempty+  where+    go ::+      Monad m =>+      ByteString ->+      ConduitT (Either EncryptError ByteString) o m (Either EncryptError ByteString)+    go !acc = await >>= \case+      Nothing -> pure (Right acc)+      Just (Left err) -> pure (Left err)+      Just (Right bs) -> go (acc <> bs)++-- | Stream and age encrypt a byte string.+--+-- Errors are returned after the pipeline is run. For a variant that includes+-- errors in the stream, see 'sinkEncryptEither'.+sinkEncrypt ::+  MonadIO m =>+  Recipients ->+  ConduitT ByteString o (ExceptT EncryptError m) ByteString+sinkEncrypt recipients = exceptC (sinkEncryptEither recipients)++-------------------------------------------------------------------------------+-- Decryption+-------------------------------------------------------------------------------++-- | Stream and parse an age file 'Header'.+sinkParseHeader :: Monad m => ConduitT ByteString o m (Either ParseError Header)+sinkParseHeader = sinkParserEither headerParser++-- | Stream and parse an age file+-- [payload](https://github.com/C2SP/C2SP/blob/91935d7157cb3860351ffebbad1e6f6153e8efc8/age.md#payload)+-- into 64 KiB chunks of ciphertext.+conduitParseCiphertextPayloadChunk ::+  Monad m =>+  ConduitT ByteString (Either ParseError CiphertextPayloadChunk) m ()+conduitParseCiphertextPayloadChunk =+  conduitParserEither ciphertextPayloadChunkParser+    .| C.map (second snd)++-- | Error decrypting an age file payload chunk.+data DecryptPayloadChunkError+  = -- | Invalid @Poly1305@ authentication tag size.+    DecryptPayloadChunkInvalidAuthenticationTagSizeError !Int+  | -- | Invalid @Poly1305@ authentication tag.+    DecryptPayloadChunkInvalidAuthenticationTagError+      -- | Expected authentication tag.+      !ByteString+      -- | Actual authentication tag.+      !ByteString+  deriving stock (Show, Eq)++-- | Decrypt a chunk of an age file+-- [payload](https://github.com/C2SP/C2SP/blob/91935d7157cb3860351ffebbad1e6f6153e8efc8/age.md#payload).+decryptPayloadChunk ::+  -- | Payload key.+  PayloadKey ->+  -- | Payload chunk counter (used in constructing the @ChaCha20-Poly1305@+  -- nonce).+  PayloadChunkCounter ->+  -- | Payload chunk to be decrypted.+  CiphertextPayloadChunk ->+  Either DecryptPayloadChunkError PlaintextPayloadChunk+decryptPayloadChunk payloadKey counter chunk = do+  let isFinalChunk :: IsFinalChunk+      isFinalChunk =+        case chunk of+          CiphertextPayloadChunkNormal _ -> IsNotFinalChunk+          CiphertextPayloadChunkFinal _ -> IsFinalChunk++      chunkBs :: ByteString+      chunkBs = ciphertextPayloadChunkToBytes chunk++      ciphertext :: ByteString+      ciphertext = BS.dropEnd 16 chunkBs++      actualAuthTagBs :: ByteString+      actualAuthTagBs = BS.takeEnd 16 chunkBs++      actualAuthTagBsLen :: Int+      actualAuthTagBsLen = BS.length actualAuthTagBs++  actualAuthTag <-+    first+      (const $ DecryptPayloadChunkInvalidAuthenticationTagSizeError actualAuthTagBsLen)+      (Crypto.eitherCryptoError $ Poly1305.authTag actualAuthTagBs)++  let nonce = toChaCha20Poly1305Nonce isFinalChunk counter++      st :: ChaCha20Poly1305.State+      st = Crypto.throwCryptoError $ ChaCha20Poly1305.initialize (payloadKeyToBytes payloadKey) nonce++      plaintext :: ByteString+      st2 :: ChaCha20Poly1305.State+      (plaintext, st2) = ChaCha20Poly1305.decrypt ciphertext st++      expectedAuthTag :: Poly1305.Auth+      expectedAuthTag = ChaCha20Poly1305.finalize st2++  when+    (expectedAuthTag /= actualAuthTag)+    (Left $ DecryptPayloadChunkInvalidAuthenticationTagError (BA.convert expectedAuthTag) (BA.convert actualAuthTag))++  case isFinalChunk of+    IsNotFinalChunk ->+      case mkNormalPlaintextPayloadChunk plaintext of+        Nothing -> error "impossible: could not construct NormalPlaintextPayloadChunk chunk from decrypted NormalCiphertextPayloadChunk"+        Just c -> Right (PlaintextPayloadChunkNormal c)+    IsFinalChunk ->+      case mkFinalPlaintextPayloadChunk plaintext of+        Nothing -> error "impossible: could not construct FinalPlaintextPayloadChunk chunk from decrypted FinalCiphertextPayloadChunk"+        Just c -> Right (PlaintextPayloadChunkFinal c)++-- | Error decrypting an age file payload.+data DecryptPayloadError+  = -- | Error parsing the 'PayloadKeyNonce'.+    DecryptPayloadKeyNonceParseError !ParseError+  | -- | Ciphertext payload is null (i.e. end of input was reached without+    -- consuming any ciphertext bytes).+    --+    -- /\"Streaming decryption MUST signal an error if the end of file is reached without successfully decrypting a final chunk.\"/+    --+    -- See the+    -- [age specification](https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#payload)+    -- for more information.+    DecryptPayloadNullPayloadError+  | -- | Error parsing a ciphertext payload chunk.+    DecryptPayloadCiphertextPayloadChunkParseError !ParseError+  | -- | Encountered an empty final ciphertext payload chunk for a non-empty payload.+    --+    -- /\"The final chunk MAY be shorter than 64 KiB but MUST NOT be empty unless the whole payload is empty.\"/+    --+    -- See the+    -- [age specification](https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#payload)+    -- for more information.+    --+    -- Note that \"empty\" here refers to the result of encrypting an empty+    -- byte string with @ChaCha20-Poly1305@. Meaning that this final chunk only+    -- consists of a 16-byte @Poly1305@ authentication tag, but no @ChaCha20@+    -- ciphertext.+    DecryptPayloadNonEmptyPayloadEmptyFinalChunk+  | -- | Error decrypting a payload chunk.+    DecryptPayloadDecryptPayloadChunkError !PayloadChunkCounter !DecryptPayloadChunkError+  deriving stock (Show)++-- | Stream and decrypt an age file+-- [payload](https://github.com/C2SP/C2SP/blob/91935d7157cb3860351ffebbad1e6f6153e8efc8/age.md#payload).+conduitDecryptPayload ::+  Monad m =>+  FileKey ->+  ConduitT ByteString (Either DecryptPayloadError PlaintextPayloadChunk) m ()+conduitDecryptPayload fileKey = do+  payloadKeyNonceRes <-+    first DecryptPayloadKeyNonceParseError+      <$> sinkParserEither payloadKeyNonceParser+  case payloadKeyNonceRes of+    Left err -> yield (Left err)+    Right payloadKeyNonce ->+      -- Ensure that we try to decrypt at least one payload chunk.+      --+      -- > Streaming decryption MUST signal an error if the end of file is+      -- > reached without successfully decrypting a final chunk.+      --+      -- https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#payload+      await >>= \case+        Nothing -> yield (Left DecryptPayloadNullPayloadError)+        Just x -> do+          leftover x+          conduitParseCiphertextPayloadChunk+            .| conduitIncludeCounter+            .| conduitDecryptChunk (mkPayloadKey payloadKeyNonce fileKey)+  where+    conduitDecryptChunk ::+      Monad m =>+      PayloadKey ->+      ConduitT (PayloadChunkCounter, Either ParseError CiphertextPayloadChunk) (Either DecryptPayloadError PlaintextPayloadChunk) m ()+    conduitDecryptChunk payloadKey = go+      where+        go = await >>= \case+          Nothing -> pure ()+          Just (_, Left err) -> yield (Left $ DecryptPayloadCiphertextPayloadChunkParseError err)+          Just (counter, Right (CiphertextPayloadChunkFinal (FinalCiphertextPayloadChunk chunkBs)))+            | BS.length chunkBs == authenticationTagSize && counter /= zeroPayloadChunkCounter ->+              -- Check that the final ciphertext payload chunk is not empty+              -- unless the whole payload is empty.+              --+              -- > The final chunk MAY be shorter than 64 KiB but MUST NOT be+              -- > empty unless the whole payload is empty.+              --+              -- https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#payload+              yield (Left DecryptPayloadNonEmptyPayloadEmptyFinalChunk)+          Just (counter, Right chunk) ->+            case decryptPayloadChunk payloadKey counter chunk of+              Left err -> yield (Left $ DecryptPayloadDecryptPayloadChunkError counter err)+              Right plaintext -> do+                yield (Right plaintext)+                go++-- | Error decrypting an age file.+data DecryptError+  = -- | Error parsing the file header.+    DecryptHeaderParseError !ParseError+  | -- | @scrypt@ recipient stanza is not the only stanza in the file header.+    --+    -- As noted in the+    -- [age specification](https://github.com/C2SP/C2SP/blob/34a9210873230d2acaa4a4c9c5d4d1119b2ee77d/age.md#scrypt-recipient-stanza),+    -- no other stanzas can be specified in the header when there is an+    -- @scrypt@ stanza. This is to uphold an expectation of authentication that+    -- is implicit in password-based encryption.+    DecryptScryptStanzaNotAloneError+  | -- | Error unwrapping a recipient stanza.+    DecryptUnwrapStanzaError !UnwrapStanzaError+  | -- | Error finding any recipient stanza which corresponds to any of the+    -- provided identities.+    DecryptNoMatchingRecipientError+  | -- | Invalid header MAC.+    DecryptInvalidHeaderMacError+      -- | Expected header MAC.+      !HeaderMac+      -- | Actual header MAC.+      !HeaderMac+  | -- | Error decrypting the file payload.+    DecryptDecryptPayloadError !DecryptPayloadError+  deriving stock (Show)++-- | Stream and decrypt an age file.+--+-- Errors are returned in the stream. For a variant that only returns errors+-- after the pipeline is run, see 'conduitDecrypt'.+conduitDecryptEither ::+  Monad m =>+  NonEmpty Identity ->+  ConduitT ByteString (Either DecryptError ByteString) m ()+conduitDecryptEither identities = do+  headerRes <- first DecryptHeaderParseError <$> sinkParseHeader+  case headerRes of+    Left err -> yield (Left err)+    Right Header{hStanzas, hMac}+      | hasScryptStanza hStanzas && (length hStanzas > 1) ->+        -- If it looks like there's an scrypt recipient stanza in the header,+        -- check that it is the /only/ stanza (as described in the age+        -- specification).+        --+        -- > scrypt stanzas MAY NOT be mixed with other scrypt stanzas or+        -- > stanzas of other types+        --+        -- https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#scrypt-recipient-stanza+        yield (Left DecryptScryptStanzaNotAloneError)+      | otherwise ->+        -- Attempt to unwrap the file key from any of the recipient stanzas and+        -- decrypt the payload.+        case unwrapStanzasWithIdentities identities hStanzas of+          Left err -> yield (Left $ DecryptUnwrapStanzaError err)+          Right Nothing -> yield (Left DecryptNoMatchingRecipientError)+          Right (Just fk) -> do+            -- TODO: Instead of 'computeHeaderMac', we should instead compute+            -- the HMAC on the /actual/ serialized bytes of the parsed header.+            --+            -- The Go implementation of age does the same thing that we do:+            -- https://github.com/FiloSottile/age/blob/3d91014ea095e8d70f7c6c4833f89b53a96e0832/primitives.go#L52-L63+            --+            -- However, the Rust implementation does the right thing:+            -- - https://github.com/str4d/rage/blob/d7c727aef96cc007e142f5b21c0d19210154b3c7/age/src/format.rs#L27-L33+            -- - https://github.com/str4d/rage/blob/d7c727aef96cc007e142f5b21c0d19210154b3c7/age/src/format.rs#L63-L74+            let actualHeaderMac = computeHeaderMac fk hStanzas+            if hMac /= actualHeaderMac+              then yield (Left $ DecryptInvalidHeaderMacError hMac actualHeaderMac)+              else+                conduitDecryptPayload fk+                  .| C.map (bimap DecryptDecryptPayloadError plaintextPayloadChunkToBytes)+  where+    -- Predicate to check if a 'Stanza' /looks like/ it might be an @scrypt@+    -- recipient stanza.+    --+    -- We do this just by checking the first argument of the stanza.+    looksLikeScryptStanza :: Stanza -> Bool+    looksLikeScryptStanza Stanza{sTag} = sTag == scryptStanzaTag++    -- Check whether there is an @scrypt@ recipient stanza in the provided+    -- list.+    --+    -- Note that we don't do a full unwrapping of the recipient stanzas here.+    -- Instead, we use 'looksLikeScryptStanza' to check if any of the stanzas+    -- /look like/ an @scrypt@ recipient stanza.+    hasScryptStanza :: NonEmpty Stanza -> Bool+    hasScryptStanza stanzas = isJust (find looksLikeScryptStanza stanzas)++-- | Stream and decrypt an age file.+--+-- Errors are returned after the pipeline is run. For a variant that includes+-- errors in the stream, see 'conduitDecryptEither'.+conduitDecrypt ::+  Monad m =>+  NonEmpty Identity ->+  ConduitT ByteString ByteString (ExceptT DecryptError m) ()+conduitDecrypt identities =+  conduitDecryptEither identities+    .| go+  where+    go ::+      Monad m =>+      ConduitT (Either DecryptError ByteString) ByteString (ExceptT DecryptError m) ()+    go = awaitForever $ \case+      Left err -> lift (throwError err)+      Right bs -> yield bs++-- | Stream and decrypt an age file to a byte string.+--+-- Errors are returned in the stream. For a variant that only returns errors+-- after the pipeline is run, see 'sinkDecrypt'.+sinkDecryptEither ::+  Monad m =>+  NonEmpty Identity ->+  ConduitT ByteString o m (Either DecryptError ByteString)+sinkDecryptEither identities =+  conduitDecryptEither identities+    .| go mempty+  where+    go ::+      Monad m =>+      ByteString ->+      ConduitT (Either DecryptError ByteString) o m (Either DecryptError ByteString)+    go !acc = await >>= \case+      Nothing -> pure (Right acc)+      Just (Left err) -> pure (Left err)+      Just (Right bs) -> go (acc <> bs)++-- | Stream and decrypt an age file to a byte string.+--+-- Errors are returned after the pipeline is run. For a variant that includes+-- errors in the stream, see 'sinkDecryptEither'.+sinkDecrypt ::+  Monad m =>+  NonEmpty Identity ->+  ConduitT ByteString o (ExceptT DecryptError m) ByteString+sinkDecrypt identities = exceptC (sinkDecryptEither identities)
+ src/Crypto/Age/Header.hs view
@@ -0,0 +1,265 @@+-- | age file header.+module Crypto.Age.Header+  ( -- * Header+    Header (..)+  , headerBuilder+  , headerParser+    -- ** Stanza+  , Stanza (..)+  , stanzaBuilder+  , stanzaParser+    -- ** Header MAC+  , HeaderMac (..)+  , computeHeaderMac+  , headerMacBuilder+  , headerMacParser+  ) where++import Control.Monad ( void )+import Crypto.Age.Key ( FileKey, fileKeyToBytes )+import qualified Crypto.Hash as Crypto+import qualified Crypto.KDF.HKDF as HKDF+import qualified Crypto.MAC.HMAC as Crypto+import Data.Attoparsec.ByteString+  ( Parser, many', many1', sepBy1', string, take, takeWhile1 )+import Data.Attoparsec.ByteString.Base64 ( takeMNBase64Chars, takeNBase64Chars )+import Data.Attoparsec.ByteString.Char8 ( char8 )+import Data.ByteArray ( ScrubbedBytes, constEq )+import qualified Data.ByteArray as BA+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import Data.ByteString.Base64.Extra+  ( decodeBase64StdUnpadded, encodeBase64StdUnpadded )+import Data.ByteString.Builder ( Builder )+import qualified Data.ByteString.Builder as Builder+import Data.ByteString.Extra ( chunksOf )+import Data.Foldable ( foldMap' )+import qualified Data.List as L+import qualified Data.List.Compat as LC+import Data.List.NonEmpty ( NonEmpty )+import qualified Data.List.NonEmpty as NE+import qualified Data.Text as T+import Prelude hiding ( take )++-- | Parse an ABNF @VCHAR@ string as specified in+-- [RFC 2234 section 6.1](https://www.rfc-editor.org/rfc/rfc2234#section-6.1).+vcharStringParser :: Parser ByteString+vcharStringParser = takeWhile1 (`elem` [0x21 .. 0x7E])++-- | Parse and decode @n@ characters of unpadded base64.+base64UnpaddedParser :: Int -> Parser ByteString+base64UnpaddedParser n = do+  b64 <- take n+  case decodeBase64StdUnpadded b64 of+    Left err -> fail (T.unpack err)+    Right bs -> pure bs++-- | Stanza.+data Stanza = Stanza+  { -- | Stanza tag (technically, the first argument of the stanza).+    sTag :: !ByteString+  , -- | Stanza arguments (every argument after the tag).+    sArgs :: ![ByteString]+  , -- | Base64-decoded stanza body.+    sBody :: !ByteString+  }+  deriving stock (Show, Eq)++stanzaBegin :: ByteString+stanzaBegin = "-> "++stanzaBuilder :: Stanza -> Builder+stanzaBuilder s = argLineBuilder <> bodyBuilder+  where+    Stanza+      { sTag+      , sArgs+      , sBody+      } = s++    bodyB64 :: ByteString+    bodyB64 = encodeBase64StdUnpadded sBody++    bodyB64Chunks :: [ByteString]+    bodyB64Chunks = chunksOf 64 bodyB64++    argLineBuilder :: Builder+    argLineBuilder =+      Builder.byteString stanzaBegin+        <> Builder.byteString (BS.intercalate " " (sTag : sArgs))+        <> Builder.byteString "\n"++    fullBodyLinesBuilder :: [ByteString] -> Builder+    fullBodyLinesBuilder [] = mempty+    fullBodyLinesBuilder xs =+      Builder.byteString (BS.intercalate "\n" xs)+        <> Builder.byteString "\n"++    finalBodyLineBuilder :: ByteString -> Builder+    finalBodyLineBuilder bs+      | BS.length bs == 64 = Builder.byteString (bs <> "\n\n")+      | otherwise = Builder.byteString (bs <> "\n")++    bodyBuilder :: Builder+    bodyBuilder =+      case LC.unsnoc bodyB64Chunks of+        Nothing -> Builder.byteString "\n"+        Just (cs, c) ->+          fullBodyLinesBuilder cs+            <> finalBodyLineBuilder c++stanzaParser :: Parser Stanza+stanzaParser = do+  void $ string stanzaBegin+  (tag, args) <- argLineParser+  void $ char8 '\n'+  body <- bodyParser+  pure Stanza+    { sTag = tag+    , sArgs = args+    , sBody = body+    }+  where+    argLineParser :: Parser (ByteString, [ByteString])+    argLineParser = do+      args <- vcharStringParser `sepBy1'` char8 ' '+      case L.uncons args of+        Nothing -> error "stanzaParser: impossible: no elements in a list parsed using sepBy1"+        Just (tag, rest) -> pure (tag, rest)++    fullBodyLineParser :: Parser ByteString+    fullBodyLineParser = takeNBase64Chars 64 <* char8 '\n'++    finalBodyLineParser :: Parser ByteString+    finalBodyLineParser = takeMNBase64Chars 0 63 <* char8 '\n'++    -- Parse the line-wrapped base64-encoded stanza body.+    --+    -- This parser returns each base64-encoded line of the stanza body.+    wrappedBodyParser :: Parser [ByteString]+    wrappedBodyParser = do+      fullLines <- many' fullBodyLineParser+      finalLine <- finalBodyLineParser+      pure $ fullLines ++ [finalLine]++    -- Parse and decode the stanza body.+    --+    -- This parses each base64-encoded line of the stanza body, concatenates+    -- them, and then base64 decodes the result.+    bodyParser :: Parser ByteString+    bodyParser = do+      bodyLines <- wrappedBodyParser+      case decodeBase64StdUnpadded (BS.concat bodyLines) of+        Left err -> fail (T.unpack err)+        Right bs -> pure bs++-- | Header MAC.+--+-- Note that this type's 'Eq' instance performs a constant-time equality+-- check.+newtype HeaderMac = HeaderMac+  { unHeaderMac :: Crypto.HMAC Crypto.SHA256 }++instance Show HeaderMac where+  show = show . Crypto.hmacGetDigest . unHeaderMac++instance Eq HeaderMac where+  HeaderMac x == HeaderMac y = x `constEq` y++-- | Compute the 'HeaderMac' for a 'Header' which would contain the provided 'Stanza's.+computeHeaderMac :: FileKey -> NonEmpty Stanza -> HeaderMac+computeHeaderMac fk stanzas = do+  let info :: ByteString+      info = "header"++      prk :: HKDF.PRK Crypto.SHA256+      prk = HKDF.extract BS.empty (fileKeyToBytes fk)++      hmacKey :: ScrubbedBytes+      hmacKey = HKDF.expand prk info 32++      partialHeaderBs :: ByteString+      partialHeaderBs =+        BS.toStrict . Builder.toLazyByteString $+          headerBuilder' (PartialHeader stanzas) Nothing++  HeaderMac (Crypto.hmac hmacKey partialHeaderBs)++headerMacMark :: ByteString+headerMacMark = "---"++headerMacBegin :: ByteString+headerMacBegin = headerMacMark <> " "++headerMacBuilder :: HeaderMac -> Builder+headerMacBuilder (HeaderMac h) =+  Builder.byteString headerMacBegin+    <> Builder.byteString (encodeBase64StdUnpadded . BA.convert $ Crypto.hmacGetDigest h)++headerMacParser :: Parser HeaderMac+headerMacParser = do+  void $ string headerMacBegin+  macBs <- base64UnpaddedParser 43+  case Crypto.digestFromByteString macBs of+    Nothing -> fail "invalid header HMAC"+    Just d -> pure $ HeaderMac (Crypto.HMAC d)++-- | Partial header.+--+-- This is an internal data type used for computing the header MAC.+newtype PartialHeader = PartialHeader (NonEmpty Stanza)+  deriving stock (Show, Eq)++-- | Convert a 'Header' to a 'PartialHeader'.+toPartialHeader :: Header -> PartialHeader+toPartialHeader Header{hStanzas} = PartialHeader hStanzas++-- | Header.+data Header = Header+  { -- | Stanzas.+    hStanzas :: !(NonEmpty Stanza)+  , -- | Header MAC.+    --+    -- Note that the value of this field is /not/ guaranteed to be+    -- cryptographically verified. For example, if this value was constructed+    -- as the result of parsing an age file, that only means that the value was+    -- deemed to be /syntactically/ valid. It does not mean that it was+    -- verified to be /cryptographically/ valid.+    hMac :: !HeaderMac+  } deriving stock (Show, Eq)++headerVersionLine :: ByteString+headerVersionLine = "age-encryption.org/v1\n"++-- | Encoder which can either encode a partial header (i.e. one with no MAC;+-- which is used for computing the MAC) or a full header (i.e. one with a MAC).+--+-- Note that this function is not intended to be exported.+headerBuilder' :: PartialHeader -> Maybe HeaderMac -> Builder+headerBuilder' (PartialHeader stanzas) mbHeaderMac =+  Builder.byteString headerVersionLine+    <> foldMap' stanzaBuilder stanzas+    <> case mbHeaderMac of+      Just m -> headerMacBuilder m <> Builder.byteString "\n"+      Nothing -> Builder.byteString headerMacMark++-- | 'Header' encoder.+headerBuilder :: Header -> Builder+headerBuilder h@Header{hMac} =+  headerBuilder' (toPartialHeader h) (Just hMac)++-- | 'Header' parser.+headerParser :: Parser Header+headerParser = do+  void $ string headerVersionLine+  stanzas <- many1' stanzaParser+  nonEmptyStanzas <-+    case NE.nonEmpty stanzas of+      Nothing -> fail "expecting one or more recipient stanzas"+      Just x -> pure x+  mac <- headerMacParser+  void $ char8 '\n'+  pure Header+    { hStanzas = nonEmptyStanzas+    , hMac = mac+    }
+ src/Crypto/Age/Identity.hs view
@@ -0,0 +1,126 @@+module Crypto.Age.Identity+  ( -- * Identity+    Identity (..)++    -- ** @scrypt@+  , ScryptIdentity (..)++    -- ** X25519+  , X25519Identity (..)+  , bytesToX25519Identity+  , x25519IdentityToBytes+  , toX25519Recipient+  , generateX25519Identity+  , encodeX25519Identity+  , DecodeX25519IdentityError (..)+  , decodeX25519Identity+  ) where++import qualified Codec.Binary.Bech32 as Bech32+import Control.Monad ( when )+import Crypto.Age.Recipient ( X25519Recipient (..) )+import Crypto.Age.Scrypt ( Passphrase, WorkFactor )+import qualified Crypto.Error as Crypto+import qualified Crypto.PubKey.Curve25519 as Curve25519+import Data.Bifunctor ( first )+import Data.ByteArray ( ScrubbedBytes )+import qualified Data.ByteArray as BA+import Data.Text ( Text )+import qualified Data.Text as T+import Prelude++-- | [@scrypt@ identity](https://github.com/C2SP/C2SP/blob/91935d7157cb3860351ffebbad1e6f6153e8efc8/age.md#the-scrypt-recipient-type).+data ScryptIdentity = ScryptIdentity+  { -- | Passphrase.+    siPassphrase :: !Passphrase+  , -- | Maximum work factor permitted for this identity.+    siMaxWorkFactor :: !WorkFactor+  } deriving stock (Show, Eq)++-- | [X25519 identity](https://github.com/C2SP/C2SP/blob/91935d7157cb3860351ffebbad1e6f6153e8efc8/age.md#the-x25519-recipient-type).+--+-- Note that this type's 'Eq' instance performs a constant-time equality+-- check.+newtype X25519Identity = X25519Identity+  { unX25519Identity :: Curve25519.SecretKey }+  deriving newtype (Show, Eq)++-- | Construct an 'X25519Identity' from the raw bytes of a Curve25519 secret+-- key.+--+-- If the provided byte string does not have a length of 32 (256 bits),+-- 'Nothing' is returned.+bytesToX25519Identity :: ScrubbedBytes -> Maybe X25519Identity+bytesToX25519Identity =+  (X25519Identity <$>)+    . Crypto.maybeCryptoError+    . Curve25519.secretKey++-- | Get the raw Curve25519 secret key bytes associated with an+-- 'X25519Identity'.+x25519IdentityToBytes :: X25519Identity -> ScrubbedBytes+x25519IdentityToBytes = BA.convert . unX25519Identity++-- | Get the 'X25519Recipient' which corresponds to the given 'X25519Identity'.+toX25519Recipient :: X25519Identity -> X25519Recipient+toX25519Recipient (X25519Identity sk) =+  X25519Recipient (Curve25519.toPublic sk)++-- | Randomly generate a 'X25519Identity'.+generateX25519Identity :: IO X25519Identity+generateX25519Identity = X25519Identity <$> Curve25519.generateSecretKey++x25519IdentityBech32Hrp :: Bech32.HumanReadablePart+x25519IdentityBech32Hrp =+  case Bech32.humanReadablePartFromText "AGE-SECRET-KEY-" of+    Left _ -> error "x25519IdentityBech32Hrp: impossible: \"AGE-SECRET-KEY-\" is an invalid HRP"+    Right hrp -> hrp++-- | Encode an 'X25519Identity' as+-- [Bech32](https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki).+encodeX25519Identity :: X25519Identity -> Either Bech32.EncodingError Text+encodeX25519Identity i =+  T.toUpper+    <$> Bech32.encode+      x25519IdentityBech32Hrp+      (Bech32.dataPartFromBytes . BA.convert $ x25519IdentityToBytes i)++-- | Error decoding an 'X25519Identity' from Bech32.+data DecodeX25519IdentityError+  = -- | Bech32 decoding error.+    DecodeX25519IdentityBech32DecodingError !Bech32.DecodingError+  | -- | Invalid Bech32 human-readable part.+    DecodeX25519IdentityInvalidHumanReadablePartError+      -- | Expected Bech32 human-readable part.+      !Bech32.HumanReadablePart+      -- | Actual Bech32 human-readable part.+      !Bech32.HumanReadablePart+  | -- | Invalid Bech32 data part.+    DecodeX25519IdentityInvalidDataPartError+  | -- | Invalid Curve25519 secret key size.+    DecodeX25519IdentityInvalidSecretKeySizeError+  deriving stock (Show, Eq)++-- | Decode an 'X25519Identity' from+-- [Bech32](https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki).+decodeX25519Identity :: Text -> Either DecodeX25519IdentityError X25519Identity+decodeX25519Identity t = do+  (hrp, dp) <- first DecodeX25519IdentityBech32DecodingError (Bech32.decode t)+  when+    (x25519IdentityBech32Hrp /= hrp)+    (Left $ DecodeX25519IdentityInvalidHumanReadablePartError x25519IdentityBech32Hrp hrp)+  dpBs <-+    case Bech32.dataPartToBytes dp of+      Nothing -> Left DecodeX25519IdentityInvalidDataPartError+      Just bs -> Right bs+  case bytesToX25519Identity (BA.convert dpBs) of+    Nothing -> Left DecodeX25519IdentityInvalidSecretKeySizeError+    Just i -> Right i++-- | age identity.+data Identity+  = -- | @scrypt@ identity.+    IdentityScrypt !ScryptIdentity+  | -- | X25519 identity.+    IdentityX25519 !X25519Identity+  deriving stock (Show, Eq)
+ src/Crypto/Age/Identity/Stanza.hs view
@@ -0,0 +1,310 @@+{-# LANGUAGE BangPatterns #-}++-- | Unwrapping file keys from recipient stanzas.+module Crypto.Age.Identity.Stanza+  ( -- * Recipient stanza unwrapping+    UnwrapStanzaError (..)+  , unwrapStanzas+  , unwrapStanzasWithIdentities++  -- ** @scrypt@+  , UnwrapScryptStanzaError (..)+  , unwrapScryptStanza++  -- ** X25519+  , UnwrapX25519StanzaError (..)+  , unwrapX25519Stanza+  ) where++import Control.Monad ( when )+import Crypto.Age.Header ( Stanza (..) )+import Crypto.Age.Identity+  ( Identity (..), ScryptIdentity (..), X25519Identity (..) )+import Crypto.Age.Key ( FileKey, bytesToFileKey )+import Crypto.Age.Recipient.Stanza+  ( ParseScryptStanzaError (..)+  , ParseX25519StanzaError (..)+  , ScryptRecipientStanza (..)+  , X25519RecipientStanza (..)+  , toScryptRecipientStanza+  , toX25519RecipientStanza+  )+import Crypto.Age.Scrypt ( Passphrase (..), WorkFactor (..), saltToBytes )+import qualified Crypto.Cipher.ChaChaPoly1305 as ChaCha20Poly1305+import qualified Crypto.Error as Crypto+import qualified Crypto.Hash as Crypto+import qualified Crypto.KDF.HKDF as HKDF+import qualified Crypto.KDF.Scrypt as Scrypt+import qualified Crypto.MAC.Poly1305 as Poly1305+import qualified Crypto.PubKey.Curve25519 as Curve25519+import Data.Bifunctor ( first )+import Data.ByteArray ( ScrubbedBytes )+import qualified Data.ByteArray as BA+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import Data.List.NonEmpty ( NonEmpty )+import qualified Data.List.NonEmpty as NE+import Prelude++-- | Error unwrapping a recipient stanza.+data UnwrapStanzaError+  = -- | Error unwrapping an @scrypt@ recipient stanza.+    UnwrapStanzaUnwrapScryptStanzaError !UnwrapScryptStanzaError+  | -- | Error unwrapping a X25519 recipient stanza.+    UnwrapStanzaUnwrapX25519StanzaError !UnwrapX25519StanzaError+  deriving stock (Show, Eq)++-- | Attempt to unwrap any of the provided stanzas.+--+-- Note that if there isn't a recipient stanza which corresponds to the+-- provided identity, 'Right' 'Nothing' will be returned.+unwrapStanzas ::+  Identity ->+  NonEmpty Stanza ->+  Either UnwrapStanzaError (Maybe FileKey)+unwrapStanzas identity stanzas = go (Right Nothing) (NE.toList stanzas)+  where+    unwrap :: Stanza -> Either UnwrapStanzaError (Maybe FileKey)+    unwrap s =+      case identity of+        IdentityScrypt i ->+          case unwrapScryptStanza i s of+            Left (UnwrapScryptStanzaParseScryptStanzaError (ParseScryptStanzaInvalidTagError _ _)) ->+              -- This is not an @scrypt@ recipient stanza.+              Right Nothing+            Left (UnwrapScryptStanzaWorkFactorExceedsMaximumError _ _) ->+              -- The recipient's work factor exceeding the identity's+              -- configured maximum is not a fatal error.+              Right Nothing+            Left (UnwrapScryptStanzaInvalidAuthenticationTagError _ _) ->+              -- Failure to verify the authentication tag is not a fatal error.+              --+              -- This error essentially just indicates that decryption has+              -- failed because this stanza is not intended for this identity.+              Right Nothing+            Left err -> Left (UnwrapStanzaUnwrapScryptStanzaError err)+            Right fk -> Right (Just fk)+        IdentityX25519 i ->+          case unwrapX25519Stanza i s of+            Left (UnwrapX25519StanzaParseX25519StanzaError (ParseX25519StanzaInvalidTagError _ _)) ->+              -- This is not a X25519 recipient stanza.+              Right Nothing+            Left (UnwrapX25519StanzaInvalidAuthenticationTagError _ _) ->+              -- Failure to verify the authentication tag is not a fatal error.+              --+              -- This error essentially just indicates that decryption has+              -- failed because this stanza is not intended for this identity.+              Right Nothing+            Left err -> Left (UnwrapStanzaUnwrapX25519StanzaError err)+            Right fk -> Right (Just fk)++    go ::+      Either UnwrapStanzaError (Maybe FileKey) ->+      [Stanza] ->+      Either UnwrapStanzaError (Maybe FileKey)+    go (Right Nothing) (s : ss) = go (unwrap s) ss+    go !acc _ = acc++-- | Attempt to unwrap any of the provided stanzas using any of the provided+-- identities.+--+-- Note that if there isn't a recipient stanza which corresponds to any of the+-- provided identities, 'Right' 'Nothing' will be returned.+unwrapStanzasWithIdentities ::+  NonEmpty Identity ->+  NonEmpty Stanza ->+  Either UnwrapStanzaError (Maybe FileKey)+unwrapStanzasWithIdentities identities stanzas = go (Right Nothing) (NE.toList identities)+  where+    go ::+      Either UnwrapStanzaError (Maybe FileKey) ->+      [Identity] ->+      Either UnwrapStanzaError (Maybe FileKey)+    go (Right Nothing) (i : is) = go (unwrapStanzas i stanzas) is+    go !acc _ = acc++-- | Error unwrapping an @scrypt@ recipient stanza.+data UnwrapScryptStanzaError+  = -- | 'ParseScryptStanzaError' that occurred during unwrapping.+    UnwrapScryptStanzaParseScryptStanzaError !ParseScryptStanzaError+  | -- | Recipient's work factor exceeds the identity's maximum work factor.+    UnwrapScryptStanzaWorkFactorExceedsMaximumError+      -- | Identity's maximum work factor.+      !WorkFactor+      -- | Recipient's work factor.+      !WorkFactor+  | -- | Invalid ciphertext size.+    UnwrapScryptStanzaInvalidCiphertextSizeError !Int+  | -- | Invalid @Poly1305@ authentication tag size.+    UnwrapScryptStanzaInvalidAuthenticationTagSizeError !Int+  | -- | Invalid @Poly1305@ authentication tag.+    UnwrapScryptStanzaInvalidAuthenticationTagError+      -- | Expected authentication tag.+      !ByteString+      -- | Actual authentication tag.+      !ByteString+  | -- | Decrypted file key is invalid.+    UnwrapScryptStanzaInvalidFileKeyError+  deriving stock (Show, Eq)++-- | Attempt to unwrap a 'Stanza' using a 'ScryptIdentity'.+unwrapScryptStanza ::+  ScryptIdentity ->+  Stanza ->+  Either UnwrapScryptStanzaError FileKey+unwrapScryptStanza i s = do+  ScryptRecipientStanza+    { srsSalt+    , srsWorkFactor+    , srsEncryptedFileKey+    } <- first UnwrapScryptStanzaParseScryptStanzaError (toScryptRecipientStanza s)++  let WorkFactor workFactorW8 = srsWorkFactor++  when (workFactorW8 > maxWorkFactorW8) (Left $ UnwrapScryptStanzaWorkFactorExceedsMaximumError siMaxWorkFactor srsWorkFactor)++  let ciphertext :: ByteString+      actualAuthTagBs :: ByteString+      (ciphertext, actualAuthTagBs) = BS.splitAt 16 srsEncryptedFileKey++      ciphertextLen :: Int+      ciphertextLen = BS.length ciphertext++      actualAuthTagBsLen :: Int+      actualAuthTagBsLen = BS.length actualAuthTagBs++  when (ciphertextLen /= 16) (Left $ UnwrapScryptStanzaInvalidCiphertextSizeError ciphertextLen)++  actualAuthTag <-+    first+      (const $ UnwrapScryptStanzaInvalidAuthenticationTagSizeError actualAuthTagBsLen)+      (Crypto.eitherCryptoError $ Poly1305.authTag actualAuthTagBs)++  let salt :: ByteString+      salt = "age-encryption.org/v1/scrypt" <> saltToBytes srsSalt++      params :: Scrypt.Parameters+      params =+        Scrypt.Parameters+          { Scrypt.n = 2 ^ workFactorW8+          , Scrypt.r = 8+          , Scrypt.p = 1+          , Scrypt.outputLength = 32+          }++      wrapKey :: ScrubbedBytes+      wrapKey = Scrypt.generate params passphrase salt++      nonce :: ChaCha20Poly1305.Nonce+      nonce = Crypto.throwCryptoError $ ChaCha20Poly1305.nonce12 (BS.replicate 12 0x00)++      st :: ChaCha20Poly1305.State+      st = Crypto.throwCryptoError $ ChaCha20Poly1305.initialize wrapKey nonce++      plaintext :: ScrubbedBytes+      st2 :: ChaCha20Poly1305.State+      (plaintext, st2) = ChaCha20Poly1305.decrypt (BA.convert ciphertext) st++      expectedAuthTag :: Poly1305.Auth+      expectedAuthTag = ChaCha20Poly1305.finalize st2++  when+    (expectedAuthTag /= actualAuthTag)+    (Left $ UnwrapScryptStanzaInvalidAuthenticationTagError (BA.convert expectedAuthTag) (BA.convert actualAuthTag))++  case bytesToFileKey plaintext of+    Nothing -> Left UnwrapScryptStanzaInvalidFileKeyError+    Just fk -> Right fk+  where+    ScryptIdentity+      { siPassphrase = Passphrase passphrase+      , siMaxWorkFactor+      } = i++    WorkFactor maxWorkFactorW8 = siMaxWorkFactor++-- | Error unwrapping a X25519 recipient stanza.+data UnwrapX25519StanzaError+  = -- | 'ParseX25519StanzaError' that occurred during unwrapping.+    UnwrapX25519StanzaParseX25519StanzaError !ParseX25519StanzaError+  | -- | Invalid ciphertext size.+    UnwrapX25519StanzaInvalidCiphertextSizeError !Int+  | -- | Invalid @Poly1305@ authentication tag size.+    UnwrapX25519StanzaInvalidAuthenticationTagSizeError !Int+  | -- | Computed DH shared secret is an all-zero value.+    UnwrapX25519StanzaAllZeroSharedSecretError+  | -- | Invalid @Poly1305@ authentication tag.+    UnwrapX25519StanzaInvalidAuthenticationTagError+      -- | Expected authentication tag.+      !ScrubbedBytes+      -- | Actual authentication tag.+      !ScrubbedBytes+  | -- | Decrypted file key is invalid.+    UnwrapX25519StanzaInvalidFileKeyError+  deriving stock (Show, Eq)++-- | Attempt to unwrap a 'Stanza' using a 'X25519Identity'.+unwrapX25519Stanza :: X25519Identity -> Stanza -> Either UnwrapX25519StanzaError FileKey+unwrapX25519Stanza (X25519Identity sk) s = do+  X25519RecipientStanza+    { xrsSenderPublicKey = ephemeralShare+    , xrsEncryptedFileKey+    } <- first UnwrapX25519StanzaParseX25519StanzaError (toX25519RecipientStanza s)++  let ciphertext :: ByteString+      actualAuthTagBs :: ByteString+      (ciphertext, actualAuthTagBs) = BS.splitAt 16 xrsEncryptedFileKey++      ciphertextLen :: Int+      ciphertextLen = BS.length ciphertext++      actualAuthTagBsLen :: Int+      actualAuthTagBsLen = BS.length actualAuthTagBs++  when (ciphertextLen /= 16) (Left $ UnwrapX25519StanzaInvalidCiphertextSizeError ciphertextLen)++  actualAuthTag <-+    first+      (const $ UnwrapX25519StanzaInvalidAuthenticationTagSizeError actualAuthTagBsLen)+      (Crypto.eitherCryptoError $ Poly1305.authTag actualAuthTagBs)++  let salt :: ByteString+      salt = BA.convert ephemeralShare <> BA.convert (Curve25519.toPublic sk)++      info :: ByteString+      info = "age-encryption.org/v1/X25519"++      zeroSharedSecret :: Curve25519.DhSecret+      zeroSharedSecret = Crypto.throwCryptoError $ Curve25519.dhSecret (BA.replicate 32 0x00 :: ScrubbedBytes)++      sharedSecret :: Curve25519.DhSecret+      sharedSecret = Curve25519.dh ephemeralShare sk++  when (sharedSecret == zeroSharedSecret) (Left UnwrapX25519StanzaAllZeroSharedSecretError)++  let prk :: HKDF.PRK Crypto.SHA256+      prk = HKDF.extract salt sharedSecret++      wrapKey :: ScrubbedBytes+      wrapKey = HKDF.expand prk info 32++      nonce :: ChaCha20Poly1305.Nonce+      nonce = Crypto.throwCryptoError $ ChaCha20Poly1305.nonce12 (BS.replicate 12 0x00)++      st :: ChaCha20Poly1305.State+      st = Crypto.throwCryptoError $ ChaCha20Poly1305.initialize wrapKey nonce++      plaintext :: ScrubbedBytes+      st2 :: ChaCha20Poly1305.State+      (plaintext, st2) = ChaCha20Poly1305.decrypt (BA.convert ciphertext) st++      expectedAuthTag :: Poly1305.Auth+      expectedAuthTag = ChaCha20Poly1305.finalize st2++  when+    (expectedAuthTag /= actualAuthTag)+    (Left $ UnwrapX25519StanzaInvalidAuthenticationTagError (BA.convert expectedAuthTag) (BA.convert actualAuthTag))++  case bytesToFileKey plaintext of+    Nothing -> Left UnwrapX25519StanzaInvalidFileKeyError+    Just fk -> Right fk
+ src/Crypto/Age/Key.hs view
@@ -0,0 +1,163 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE PatternSynonyms #-}++-- | age cryptographic keys.+module Crypto.Age.Key+  ( -- * File key+    FileKey (FileKey)+  , bytesToFileKey+  , fileKeyToBytes+  , generateFileKey++    -- * Payload key nonce+  , PayloadKeyNonce (PayloadKeyNonce)+  , bytesToPayloadKeyNonce+  , payloadKeyNonceToBytes+  , generatePayloadKeyNonce+  , payloadKeyNonceBuilder+  , payloadKeyNonceParser++    -- * Payload key+  , PayloadKey (PayloadKey)+  , bytesToPayloadKey+  , payloadKeyToBytes+  , mkPayloadKey+  ) where++import qualified Crypto.Hash as Crypto+import qualified Crypto.KDF.HKDF as Crypto+import qualified Crypto.Random as Crypto+import Data.Attoparsec.ByteString ( Parser, take )+import Data.ByteArray ( ScrubbedBytes )+import qualified Data.ByteArray as BA+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import Data.ByteString.Builder ( Builder )+import qualified Data.ByteString.Builder as Builder+import Prelude hiding ( take )++-- | Symmetric+-- [file key](https://github.com/C2SP/C2SP/blob/91935d7157cb3860351ffebbad1e6f6153e8efc8/age.md#file-key).+--+-- Note that this type's 'Eq' instance performs a constant-time equality check.+newtype FileKey = MkFileKey+  { unFileKey :: ScrubbedBytes }+  deriving newtype (Eq)++pattern FileKey :: ScrubbedBytes -> FileKey+pattern FileKey bs <- MkFileKey bs++{-# COMPLETE FileKey #-}++-- | Construct a 'FileKey' from bytes.+--+-- If the provided byte string does not have a length of 16 (128 bits),+-- 'Nothing' is returned.+bytesToFileKey :: ScrubbedBytes -> Maybe FileKey+bytesToFileKey bs+  | BA.length bs == 16 = Just (MkFileKey bs)+  | otherwise = Nothing++-- | Get the raw bytes associated with a 'FileKey'.+fileKeyToBytes :: FileKey -> ScrubbedBytes+fileKeyToBytes = unFileKey++-- | Randomly generate a 'FileKey' as defined in the+-- [age specification](https://github.com/C2SP/C2SP/blob/91935d7157cb3860351ffebbad1e6f6153e8efc8/age.md#file-key).+generateFileKey :: IO FileKey+generateFileKey = do+  bs <- Crypto.getRandomBytes 16 :: IO ScrubbedBytes+  case bytesToFileKey bs of+    Just x -> pure x+    Nothing -> error "generateFileKey: impossible: failed to randomly generate 16 bytes"++-- | Payload key nonce.+--+-- In accordance with the+-- [age specification](https://github.com/C2SP/C2SP/blob/34a9210873230d2acaa4a4c9c5d4d1119b2ee77d/age.md#payload),+-- this value is used as an extractor salt in the @HKDF-Extract@ step when+-- deriving a 'PayloadKey' with @HKDF-SHA256@.+newtype PayloadKeyNonce = MkPayloadKeyNonce+  { unPayloadKeyNonce :: ByteString }+  deriving newtype (Show, Eq)++pattern PayloadKeyNonce :: ByteString -> PayloadKeyNonce+pattern PayloadKeyNonce bs <- MkPayloadKeyNonce bs++{-# COMPLETE PayloadKeyNonce #-}++-- | Construct a 'PayloadKeyNonce' from bytes.+--+-- If the provided byte string does not have a length of 16 (128 bits),+-- 'Nothing' is returned.+bytesToPayloadKeyNonce :: ByteString -> Maybe PayloadKeyNonce+bytesToPayloadKeyNonce bs+  | BS.length bs == 16 = Just (MkPayloadKeyNonce bs)+  | otherwise = Nothing++-- | Get the raw bytes associated with a 'PayloadKeyNonce'.+payloadKeyNonceToBytes :: PayloadKeyNonce -> ByteString+payloadKeyNonceToBytes = unPayloadKeyNonce++-- | Randomly generate a 'PayloadKeyNonce' as defined in the+-- [age specification](https://github.com/C2SP/C2SP/blob/34a9210873230d2acaa4a4c9c5d4d1119b2ee77d/age.md#payload).+generatePayloadKeyNonce :: IO PayloadKeyNonce+generatePayloadKeyNonce = do+  bs <- Crypto.getRandomBytes 16 :: IO ByteString+  case bytesToPayloadKeyNonce bs of+    Just x -> pure x+    Nothing -> error "generatePayloadKeyNonce: impossible: failed to randomly generate 16 bytes"++-- | 'PayloadKeyNonce' encoder.+payloadKeyNonceBuilder :: PayloadKeyNonce -> Builder+payloadKeyNonceBuilder = Builder.byteString . payloadKeyNonceToBytes++-- | 'PayloadKeyNonce' parser.+payloadKeyNonceParser :: Parser PayloadKeyNonce+payloadKeyNonceParser = bytesToPayloadKeyNonce <$> take 16 >>= \case+  Just x -> pure x+  Nothing -> error "impossible: could not construct a PayloadKeyNonce from 16 bytes."++-- | Symmetric+-- [payload key](https://github.com/C2SP/C2SP/blob/91935d7157cb3860351ffebbad1e6f6153e8efc8/age.md#payload).+--+-- Note that this type's 'Eq' instance performs a constant-time equality check.+newtype PayloadKey = MkPayloadKey+  { unPayloadKey :: ScrubbedBytes }+  deriving newtype (Eq)++pattern PayloadKey :: ScrubbedBytes -> PayloadKey+pattern PayloadKey bs <- MkPayloadKey bs++{-# COMPLETE PayloadKey #-}++-- | Construct a 'PayloadKey' from bytes.+--+-- If the provided byte string does not have a length of 32 (256 bits),+-- 'Nothing' is returned.+bytesToPayloadKey :: ScrubbedBytes -> Maybe PayloadKey+bytesToPayloadKey bs+  | BA.length bs == 32 = Just (MkPayloadKey bs)+  | otherwise = Nothing++-- | Get the raw bytes associated with a 'PayloadKey'.+payloadKeyToBytes :: PayloadKey -> ScrubbedBytes+payloadKeyToBytes = unPayloadKey++-- | Construct a 'PayloadKey' as defined in the+-- [age specification](https://github.com/C2SP/C2SP/blob/91935d7157cb3860351ffebbad1e6f6153e8efc8/age.md#payload).+--+-- The 'PayloadKey' is derived via @HKDF-SHA256@ given a 'PayloadKeyNonce' as+-- the extractor salt, a 'FileKey' as the input keying material, and the string+-- @payload@ as the expansion context/info.+mkPayloadKey :: PayloadKeyNonce -> FileKey -> PayloadKey+mkPayloadKey nonce fileKey =+  case bytesToPayloadKey (Crypto.expand prk payloadKeyHkdfInfo 32) of+    Just x -> x+    Nothing -> error "mkPayloadKey: impossible: could not construct PayloadKey from 32 bytes"+  where+    payloadKeyHkdfInfo :: ByteString+    payloadKeyHkdfInfo = "payload"++    prk :: Crypto.PRK Crypto.SHA256+    prk = Crypto.extract (unPayloadKeyNonce nonce) (unFileKey fileKey)
+ src/Crypto/Age/Payload/Ciphertext.hs view
@@ -0,0 +1,138 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE PatternSynonyms #-}++-- | Encrypted chunks of an age file payload.+module Crypto.Age.Payload.Ciphertext+  ( -- * Encrypted payload chunk+    CiphertextPayloadChunk (..)+  , ciphertextPayloadChunkToBytes+  , ciphertextPayloadChunkParser+  , authenticationTagSize+  , ciphertextChunkSize+    -- ** Normal chunk+  , NormalCiphertextPayloadChunk (NormalCiphertextPayloadChunk)+  , mkNormalCiphertextPayloadChunk+  , normalCiphertextPayloadChunkParser+    -- ** Final chunk+  , FinalCiphertextPayloadChunk (FinalCiphertextPayloadChunk)+  , mkFinalCiphertextPayloadChunk+  , finalCiphertextPayloadChunkParser+  ) where++import Control.Applicative ( Alternative (..) )+import Control.Monad ( void )+import Crypto.Age.Payload.Plaintext ( plaintextChunkSize )+import Data.Attoparsec.ByteString ( Parser, atEnd, endOfInput, take )+import Data.Attoparsec.ByteString.Extra ( takeWhileMN )+import Data.ByteArray ( constEq )+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import Prelude hiding ( take )++-- | Size (in bytes) of the @Poly1305@ authentication tag associated with each+-- ciphertext payload chunk.+authenticationTagSize :: Int+authenticationTagSize = 16++-- | Size (in bytes) of a full ciphertext payload chunk.+ciphertextChunkSize :: Int+ciphertextChunkSize = plaintextChunkSize + authenticationTagSize++-------------------------------------------------------------------------------+-- Normal encrypted payload chunk+-------------------------------------------------------------------------------++-- | Normal chunk of data in an encrypted payload (i.e. not the final chunk).+--+-- Note that this type's 'Eq' instance performs a constant-time equality+-- check.+newtype NormalCiphertextPayloadChunk = MkNormalCiphertextPayloadChunk+  { unNormalCiphertextPayloadChunk :: ByteString }+  deriving newtype (Show)++instance Eq NormalCiphertextPayloadChunk where+  MkNormalCiphertextPayloadChunk x == MkNormalCiphertextPayloadChunk y = x `constEq` y++pattern NormalCiphertextPayloadChunk :: ByteString -> NormalCiphertextPayloadChunk+pattern NormalCiphertextPayloadChunk bs <- MkNormalCiphertextPayloadChunk bs++{-# COMPLETE NormalCiphertextPayloadChunk #-}++-- | Construct a 64 KiB encrypted payload chunk from a byte string.+mkNormalCiphertextPayloadChunk :: ByteString -> Maybe NormalCiphertextPayloadChunk+mkNormalCiphertextPayloadChunk bs+  | BS.length bs == ciphertextChunkSize = Just (MkNormalCiphertextPayloadChunk bs)+  | otherwise = Nothing++normalCiphertextPayloadChunkParser :: Parser NormalCiphertextPayloadChunk+normalCiphertextPayloadChunkParser = do+  bs <- take ciphertextChunkSize+  atEnd >>= \case+    True -> fail "expected more input"+    False -> pure (MkNormalCiphertextPayloadChunk bs)++-------------------------------------------------------------------------------+-- Final encrypted payload chunk+-------------------------------------------------------------------------------++-- | Final chunk of data in an encrypted payload.+--+-- Note that this type's 'Eq' instance performs a constant-time equality+-- check.+newtype FinalCiphertextPayloadChunk = MkFinalCiphertextPayloadChunk+  { unFinalCiphertextPayloadChunk :: ByteString }+  deriving newtype (Show)++instance Eq FinalCiphertextPayloadChunk where+  MkFinalCiphertextPayloadChunk x == MkFinalCiphertextPayloadChunk y = x `constEq` y++pattern FinalCiphertextPayloadChunk :: ByteString -> FinalCiphertextPayloadChunk+pattern FinalCiphertextPayloadChunk bs <- MkFinalCiphertextPayloadChunk bs++{-# COMPLETE FinalCiphertextPayloadChunk #-}++-- | Construct the final chunk of data in a payload from a byte string that is+-- 64 KiB or smaller.+mkFinalCiphertextPayloadChunk :: ByteString -> Maybe FinalCiphertextPayloadChunk+mkFinalCiphertextPayloadChunk bs+  | bsLen >= authenticationTagSize && bsLen <= ciphertextChunkSize =+      Just (MkFinalCiphertextPayloadChunk bs)+  | otherwise = Nothing+  where+    bsLen :: Int+    bsLen = BS.length bs++finalCiphertextPayloadChunkParser :: Parser FinalCiphertextPayloadChunk+finalCiphertextPayloadChunkParser = do+  bs <-+    takeWhileMN+      (fromIntegral authenticationTagSize)+      (fromIntegral ciphertextChunkSize)+      (const True)+  void endOfInput+  pure (MkFinalCiphertextPayloadChunk bs)++-------------------------------------------------------------------------------+-- Encrypted payload chunk+-------------------------------------------------------------------------------++-- | Chunk of data in an encrypted payload.+--+-- Note that this type's 'Eq' instance performs a constant-time equality+-- check.+data CiphertextPayloadChunk+  = -- | Normal chunk of ciphertext (i.e. not the final chunk).+    CiphertextPayloadChunkNormal !NormalCiphertextPayloadChunk+  | -- | Final chunk of ciphertext.+    CiphertextPayloadChunkFinal !FinalCiphertextPayloadChunk+  deriving stock (Show, Eq)++ciphertextPayloadChunkToBytes :: CiphertextPayloadChunk -> ByteString+ciphertextPayloadChunkToBytes = \case+  CiphertextPayloadChunkNormal c -> unNormalCiphertextPayloadChunk c+  CiphertextPayloadChunkFinal c -> unFinalCiphertextPayloadChunk c++ciphertextPayloadChunkParser :: Parser CiphertextPayloadChunk+ciphertextPayloadChunkParser =+  (CiphertextPayloadChunkNormal <$> normalCiphertextPayloadChunkParser)+    <|> (CiphertextPayloadChunkFinal <$> finalCiphertextPayloadChunkParser)
+ src/Crypto/Age/Payload/Counter.hs view
@@ -0,0 +1,100 @@+{-# LANGUAGE LambdaCase #-}++-- | @ChaCha20-Poly1305@ counter nonces for decrypting chunks of an age file+-- payload.+module Crypto.Age.Payload.Counter+  ( PayloadChunkCounter+  , unPayloadChunkCounter+  , mkPayloadChunkCounter+  , zeroPayloadChunkCounter+  , incrementPayloadChunkCounter+  , maxPayloadChunkCounter+  , IsFinalChunk (..)+  , toChaCha20Poly1305Nonce+  ) where++import qualified Crypto.Cipher.ChaChaPoly1305 as ChaCha20Poly1305+import qualified Crypto.Error as Crypto+import Data.Binary.Put ( runPut )+import Data.Binary.Put.Integer ( putIntegerbe )+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import Data.Word ( Word8 )+import Prelude++-- | Maximum payload chunk counter value (@0xFFFFFFFFFFFFFFFFFFFFFF@, i.e. the+-- maximum integer value that can be stored in 11 bytes).+maxPayloadChunkCounter :: Integer+maxPayloadChunkCounter = 0xFFFFFFFFFFFFFFFFFFFFFF++-- | Payload chunk counter.+newtype PayloadChunkCounter = MkPayloadChunkCounter+  { unPayloadChunkCounter :: Integer }+  deriving newtype (Show, Eq)++-- | Construct a 'PayloadChunkCounter'.+--+-- Note that, if the provided 'Integer' value is less than @0@ or greater than+-- @0xFFFFFFFFFFFFFFFFFFFFFF@ (i.e. the maximum integer value that can be+-- stored in 11 bytes), this function will return 'Nothing'.+mkPayloadChunkCounter :: Integer -> Maybe PayloadChunkCounter+mkPayloadChunkCounter x+  | x >= 0 && x <= maxPayloadChunkCounter = Just (MkPayloadChunkCounter x)+  | otherwise = Nothing++-- | Construct a 'PayloadChunkCounter' initialized to @0@.+zeroPayloadChunkCounter :: PayloadChunkCounter+zeroPayloadChunkCounter = MkPayloadChunkCounter 0++-- | Increment a 'PayloadChunkCounter' by @1@.+--+-- Note that, if this operation would result in a 'PayloadChunkCounter' that is+-- greater than @0xFFFFFFFFFFFFFFFFFFFFFF@ (i.e. the maximum integer value that+-- can be stored in 11 bytes), this function will return 'Nothing'.+incrementPayloadChunkCounter :: PayloadChunkCounter -> Maybe PayloadChunkCounter+incrementPayloadChunkCounter (MkPayloadChunkCounter x)+  | x < maxPayloadChunkCounter = Just (MkPayloadChunkCounter $ x + 1)+  | otherwise = Nothing++-- | Encode a 'PayloadChunkCounter' as an 11-byte integer in big endian format.+payloadChunkCounterToBytes :: PayloadChunkCounter -> ByteString+payloadChunkCounterToBytes (MkPayloadChunkCounter counter) = counterBs+ where+  padTo :: Int -> ByteString -> ByteString+  padTo n bs+    | n <= 0 = bs+    | BS.length bs >= n = bs+    | otherwise = BS.replicate (n - BS.length bs) 0 <> bs++  counterBs :: ByteString+  counterBs = padTo 11 $ BS.toStrict $ runPut (putIntegerbe counter)++-- | Whether this is the final payload chunk.+data IsFinalChunk+  = -- | This is the final payload chunk.+    IsFinalChunk+  | -- | This is not the final payload chunk.+    IsNotFinalChunk+  deriving stock (Show, Eq)++-- | Encode a 'PayloadChunkCounter' as a 12-byte 'ChaCha20Poly1305.Nonce'.+toChaCha20Poly1305Nonce :: IsFinalChunk -> PayloadChunkCounter -> ChaCha20Poly1305.Nonce+toChaCha20Poly1305Nonce isFinalChunk counter = nonce+  where+    finalByte :: Word8+    finalByte =+      case isFinalChunk of+        IsFinalChunk -> 0x01+        IsNotFinalChunk -> 0x00++    counterBs :: ByteString+    counterBs = payloadChunkCounterToBytes counter++    nonceBs :: ByteString+    nonceBs = counterBs <> BS.singleton finalByte++    nonce :: ChaCha20Poly1305.Nonce+    nonce =+      case ChaCha20Poly1305.nonce12 nonceBs of+        Crypto.CryptoFailed _ -> error "toChaCha20Poly1305Nonce: impossible: could not construct nonce from 12 bytes"+        Crypto.CryptoPassed n -> n
+ src/Crypto/Age/Payload/Plaintext.hs view
@@ -0,0 +1,107 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE PatternSynonyms #-}++-- | Plaintext chunks of an age file payload.+module Crypto.Age.Payload.Plaintext+  ( -- * Plaintext payload chunk+    PlaintextPayloadChunk (..)+  , plaintextPayloadChunkToBytes+  , plaintextPayloadChunkParser+  , plaintextChunkSize+    -- ** Normal chunk+  , NormalPlaintextPayloadChunk (NormalPlaintextPayloadChunk)+  , mkNormalPlaintextPayloadChunk+  , normalPlaintextPayloadChunkParser+    -- ** Final chunk+  , FinalPlaintextPayloadChunk (FinalPlaintextPayloadChunk)+  , mkFinalPlaintextPayloadChunk+  , finalPlaintextPayloadChunkParser+  ) where++import Control.Applicative ( Alternative (..) )+import Control.Monad ( void )+import Data.Attoparsec.ByteString ( Parser, atEnd, endOfInput, take )+import Data.Attoparsec.ByteString.Extra ( takeWhileMN )+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import Prelude hiding ( take )++-- | Size of a full plaintext payload chunk (64 KiB).+plaintextChunkSize :: Int+plaintextChunkSize = 64 * 1024++-------------------------------------------------------------------------------+-- Normal plaintext payload chunk+-------------------------------------------------------------------------------++-- | Normal chunk of plaintext data in a payload (i.e. not the final chunk).+newtype NormalPlaintextPayloadChunk = MkNormalPlaintextPayloadChunk+  { unNormalPlaintextPayloadChunk :: ByteString }+  deriving newtype (Show, Eq)++pattern NormalPlaintextPayloadChunk :: ByteString -> NormalPlaintextPayloadChunk+pattern NormalPlaintextPayloadChunk bs <- MkNormalPlaintextPayloadChunk bs++{-# COMPLETE NormalPlaintextPayloadChunk #-}++-- | Construct a 64 KiB plaintext payload chunk from a byte string.+mkNormalPlaintextPayloadChunk :: ByteString -> Maybe NormalPlaintextPayloadChunk+mkNormalPlaintextPayloadChunk bs+  | BS.length bs == plaintextChunkSize = Just (MkNormalPlaintextPayloadChunk bs)+  | otherwise = Nothing++normalPlaintextPayloadChunkParser :: Parser NormalPlaintextPayloadChunk+normalPlaintextPayloadChunkParser = do+  bs <- take plaintextChunkSize+  atEnd >>= \case+    True -> fail "expected more input"+    False -> pure (MkNormalPlaintextPayloadChunk bs)++-------------------------------------------------------------------------------+-- Final plaintext payload chunk+-------------------------------------------------------------------------------++-- | Final chunk of plaintext data in a payload.+newtype FinalPlaintextPayloadChunk = MkFinalPlaintextPayloadChunk+  { unFinalPlaintextPayloadChunk :: ByteString }+  deriving newtype (Show, Eq)++pattern FinalPlaintextPayloadChunk :: ByteString -> FinalPlaintextPayloadChunk+pattern FinalPlaintextPayloadChunk bs <- MkFinalPlaintextPayloadChunk bs++{-# COMPLETE FinalPlaintextPayloadChunk #-}++-- | Construct the final chunk of data in a payload from a byte string that is+-- 64 KiB or smaller.+mkFinalPlaintextPayloadChunk :: ByteString -> Maybe FinalPlaintextPayloadChunk+mkFinalPlaintextPayloadChunk bs+  | BS.length bs <= plaintextChunkSize = Just (MkFinalPlaintextPayloadChunk bs)+  | otherwise = Nothing++finalPlaintextPayloadChunkParser :: Parser FinalPlaintextPayloadChunk+finalPlaintextPayloadChunkParser = do+  bs <- takeWhileMN 0 (fromIntegral plaintextChunkSize) (const True)+  void endOfInput+  pure (MkFinalPlaintextPayloadChunk bs)++-------------------------------------------------------------------------------+-- Plaintext payload chunk+-------------------------------------------------------------------------------++-- | Chunk of data in an plaintext payload.+data PlaintextPayloadChunk+  = -- | Normal chunk of plaintext (i.e. not the final chunk).+    PlaintextPayloadChunkNormal !NormalPlaintextPayloadChunk+  | -- | Final chunk of plaintext.+    PlaintextPayloadChunkFinal !FinalPlaintextPayloadChunk+  deriving stock (Show, Eq)++plaintextPayloadChunkToBytes :: PlaintextPayloadChunk -> ByteString+plaintextPayloadChunkToBytes = \case+  PlaintextPayloadChunkNormal c -> unNormalPlaintextPayloadChunk c+  PlaintextPayloadChunkFinal c -> unFinalPlaintextPayloadChunk c++plaintextPayloadChunkParser :: Parser PlaintextPayloadChunk+plaintextPayloadChunkParser =+  (PlaintextPayloadChunkNormal <$> normalPlaintextPayloadChunkParser)+    <|> (PlaintextPayloadChunkFinal <$> finalPlaintextPayloadChunkParser)
+ src/Crypto/Age/Recipient.hs view
@@ -0,0 +1,120 @@+-- | age file recipients.+module Crypto.Age.Recipient+  ( -- * Recipients+    Recipients (..)+    -- * @scrypt@+  , ScryptRecipient (..)+    -- * X25519+  , X25519Recipient (..)+  , bytesToX25519Recipient+  , x25519RecipientToBytes+  , encodeX25519Recipient+  , DecodeX25519RecipientError (..)+  , decodeX25519Recipient+  ) where++import qualified Codec.Binary.Bech32 as Bech32+import Control.Monad ( when )+import Crypto.Age.Scrypt ( Passphrase, Salt, WorkFactor )+import qualified Crypto.Error as Crypto+import qualified Crypto.PubKey.Curve25519 as Curve25519+import Data.Bifunctor ( first )+import qualified Data.ByteArray as BA+import Data.ByteString ( ByteString )+import Data.List.NonEmpty ( NonEmpty )+import Data.Text ( Text )+import Prelude++-- | [@scrypt@ recipient](https://github.com/C2SP/C2SP/blob/91935d7157cb3860351ffebbad1e6f6153e8efc8/age.md#the-scrypt-recipient-type).+data ScryptRecipient = ScryptRecipient+  { -- | Passphrase.+    srPassphrase :: !Passphrase+  , -- | Salt.+    srSalt :: !Salt+  , -- | @scrypt@ work factor.+    srWorkFactor :: !WorkFactor+  } deriving stock (Show, Eq)++-- | [X25519 recipient](https://github.com/C2SP/C2SP/blob/91935d7157cb3860351ffebbad1e6f6153e8efc8/age.md#the-x25519-recipient-type).+newtype X25519Recipient = X25519Recipient+  { -- | Recipient's Curve25519 public key.+    unX25519Recipient :: Curve25519.PublicKey+  } deriving stock (Show, Eq)++-- | Construct an 'X25519Recipient' from the raw bytes of a Curve25519 public+-- key.+--+-- If the provided byte string does not have a length of 32 (256 bits),+-- 'Nothing' is returned.+bytesToX25519Recipient :: ByteString -> Maybe X25519Recipient+bytesToX25519Recipient =+  (X25519Recipient <$>)+    . Crypto.maybeCryptoError+    . Curve25519.publicKey++-- | Get the raw Curve25519 public key bytes associated with an+-- 'X25519Recipient'.+x25519RecipientToBytes :: X25519Recipient -> ByteString+x25519RecipientToBytes = BA.convert . unX25519Recipient++x25519RecipientBech32Hrp :: Bech32.HumanReadablePart+x25519RecipientBech32Hrp =+  case Bech32.humanReadablePartFromText "age" of+    Left _ -> error "x25519RecipientBech32Hrp: impossible: \"age\" is an invalid HRP"+    Right hrp -> hrp++-- | Encode an 'X25519Recipient' as+-- [Bech32](https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki).+encodeX25519Recipient :: X25519Recipient -> Either Bech32.EncodingError Text+encodeX25519Recipient r =+  Bech32.encode+    x25519RecipientBech32Hrp+    (Bech32.dataPartFromBytes $ x25519RecipientToBytes r)++-- | Error decoding an 'X25519Recipient' from Bech32.+data DecodeX25519RecipientError+  = -- | Bech32 decoding error.+    DecodeX25519RecipientBech32DecodingError !Bech32.DecodingError+  | -- | Invalid Bech32 human-readable part.+    DecodeX25519RecipientInvalidHumanReadablePartError+      -- | Expected Bech32 human-readable part.+      !Bech32.HumanReadablePart+      -- | Actual Bech32 human-readable part.+      !Bech32.HumanReadablePart+  | -- | Invalid Bech32 data part.+    DecodeX25519RecipientInvalidDataPartError+  | -- | Invalid Curve25519 secret key size.+    DecodeX25519RecipientInvalidSecretKeySizeError+  deriving stock (Show, Eq)++-- | Decode an 'X25519Recipient' from+-- [Bech32](https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki).+decodeX25519Recipient :: Text -> Either DecodeX25519RecipientError X25519Recipient+decodeX25519Recipient t = do+  (hrp, dp) <- first DecodeX25519RecipientBech32DecodingError (Bech32.decode t)+  when+    (x25519RecipientBech32Hrp /= hrp)+    (Left $ DecodeX25519RecipientInvalidHumanReadablePartError x25519RecipientBech32Hrp hrp)+  dpBs <-+    case Bech32.dataPartToBytes dp of+      Nothing -> Left DecodeX25519RecipientInvalidDataPartError+      Just bs -> Right bs+  case bytesToX25519Recipient dpBs of+    Nothing -> Left DecodeX25519RecipientInvalidSecretKeySizeError+    Just r -> Right r++-- | Collection of age file recipients.+data Recipients+  = -- | @scrypt@ recipient.+    --+    -- As noted in the+    -- [age specification](https://github.com/C2SP/C2SP/blob/34a9210873230d2acaa4a4c9c5d4d1119b2ee77d/age.md#scrypt-recipient-stanza),+    -- no other stanzas can be specified in the header when there is an+    -- @scrypt@ stanza. This is to uphold an expectation of authentication that+    -- is implicit in password-based encryption.+    --+    -- As a result, only one @scrypt@ recipient can be specified.+    RecipientsScrypt !ScryptRecipient+  | -- | X25519 recipients.+    RecipientsX25519 !(NonEmpty X25519Recipient)+  deriving stock (Show, Eq)
+ src/Crypto/Age/Recipient/Stanza.hs view
@@ -0,0 +1,376 @@+-- | age recipient stanzas.+module Crypto.Age.Recipient.Stanza+  ( -- * @scrypt@ recipient stanza+    ScryptRecipientStanza (..)+  , wrapFileKeyForScryptRecipient+  , ParseScryptStanzaError (..)+  , toScryptRecipientStanza+  , fromScryptRecipientStanza+  , scryptStanzaTag++    -- * X25519 recipient stanza+  , X25519RecipientStanza (..)+  , WrapX25519StanzaFileKeyError (..)+  , wrapFileKeyForX25519Recipient+  , ParseX25519StanzaError (..)+  , toX25519RecipientStanza+  , fromX25519RecipientStanza+  , x25519StanzaTag+  ) where++import Control.Monad ( unless, when )+import Crypto.Age.Header ( Stanza (..) )+import Crypto.Age.Identity ( X25519Identity (..) )+import Crypto.Age.Key ( FileKey, fileKeyToBytes )+import Crypto.Age.Recipient ( ScryptRecipient (..), X25519Recipient (..) )+import Crypto.Age.Scrypt+  ( Passphrase (..)+  , Salt+  , WorkFactor (..)+  , bytesToSalt+  , saltToBytes+  , workFactorBuilder+  , workFactorParser+  )+import qualified Crypto.Cipher.ChaChaPoly1305 as ChaCha20Poly1305+import qualified Crypto.Error as Crypto+import qualified Crypto.Hash as Crypto+import qualified Crypto.KDF.HKDF as HKDF+import qualified Crypto.KDF.Scrypt as Scrypt+import qualified Crypto.MAC.Poly1305 as Poly1305+import qualified Crypto.PubKey.Curve25519 as Curve25519+import Data.Attoparsec.ByteString ( parseOnly )+import Data.ByteArray ( ScrubbedBytes )+import qualified Data.ByteArray as BA+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import Data.ByteString.Base64.Extra+  ( decodeBase64StdUnpadded, encodeBase64StdUnpadded )+import qualified Data.ByteString.Builder as Builder+import Data.Text ( Text )+import Prelude++-- | [@scrypt@ recipient stanza](https://github.com/C2SP/C2SP/blob/34a9210873230d2acaa4a4c9c5d4d1119b2ee77d/age.md#scrypt-recipient-stanza).+data ScryptRecipientStanza = ScryptRecipientStanza+  { -- | Salt.+    srsSalt :: !Salt+  , -- | @scrypt@ work factor.+    srsWorkFactor :: !WorkFactor+  , -- | Encrypted file key.+    srsEncryptedFileKey :: !ByteString+  } deriving stock (Show, Eq)++-- | Wrap a 'FileKey' for an 'ScryptRecipient'.+wrapFileKeyForScryptRecipient ::+  ScryptRecipient ->+  FileKey ->+  ScryptRecipientStanza+wrapFileKeyForScryptRecipient r fk = do+  let salt :: ByteString+      salt = "age-encryption.org/v1/scrypt" <> saltToBytes srSalt++      params :: Scrypt.Parameters+      params =+        Scrypt.Parameters+          { Scrypt.n = 2 ^ workFactorW8+          , Scrypt.r = 8+          , Scrypt.p = 1+          , Scrypt.outputLength = 32+          }++      wrapKey :: ScrubbedBytes+      wrapKey = Scrypt.generate params passphrase salt++      nonce :: ChaCha20Poly1305.Nonce+      nonce = Crypto.throwCryptoError $ ChaCha20Poly1305.nonce12 (BS.replicate 12 0x00)++      st :: ChaCha20Poly1305.State+      st = Crypto.throwCryptoError $ ChaCha20Poly1305.initialize wrapKey nonce++      ciphertext :: ByteString+      st2 :: ChaCha20Poly1305.State+      (ciphertext, st2) = ChaCha20Poly1305.encrypt (BA.convert $ fileKeyToBytes fk) st++      authTag :: Poly1305.Auth+      authTag = ChaCha20Poly1305.finalize st2++  ScryptRecipientStanza+    { srsSalt = srSalt+    , srsWorkFactor = srWorkFactor+    , srsEncryptedFileKey = ciphertext <> BA.convert authTag+    }+  where+    ScryptRecipient+      { srPassphrase = Passphrase passphrase+      , srSalt+      , srWorkFactor+      } = r++    WorkFactor workFactorW8 = srWorkFactor++-- | @scrypt@ recipient stanza tag (i.e. the first stanza argument).+--+-- According to the+-- [age specification](https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#scrypt-recipient-stanza),+-- this is expected to be the string, @scrypt@.+scryptStanzaTag :: ByteString+scryptStanzaTag = "scrypt"++-- | Error converting a 'Stanza' to an 'ScryptRecipientStanza'.+data ParseScryptStanzaError+  = -- | Invalid tag.+    ParseScryptStanzaInvalidTagError+      -- | Expected tag.+      !ByteString+      -- | Actual tag.+      !ByteString+  | -- | Invalid number of arguments.+    ParseScryptStanzaInvalidNumberOfArgumentsError+      -- | Expected number of arguments.+      !Int+      -- | Actual number of arguments.+      !Int+  | -- | Error decoding the @scrypt@ salt from base64.+    ParseScryptStanzaSaltBase64DecodingError+      -- | Base64 decoding error.+      !Text+  | -- | Invalid @scrypt@ salt size.+    ParseScryptStanzaInvalidSaltSizeError+      -- | Expected body size in bytes.+      !Int+      -- | Actual body size in bytes.+      !Int+  | -- | Error parsing the @scrypt@ work factor.+    ParseScryptStanzaWorkFactorParseError !String+  | -- | Invalid stanza body size.+    ParseScryptStanzaInvalidBodySizeError+      -- | Expected body size in bytes.+      !Int+      -- | Actual body size in bytes.+      !Int+  deriving stock (Show, Eq)++-- | Convert a 'Stanza' in an 'ScryptRecipientStanza'.+toScryptRecipientStanza :: Stanza -> Either ParseScryptStanzaError ScryptRecipientStanza+toScryptRecipientStanza s = do+  unless (sTag == scryptStanzaTag) $ Left (ParseScryptStanzaInvalidTagError scryptStanzaTag sTag)++  (saltB64, workFactorBs) <-+    case sArgs of+      [arg1, arg2] -> Right (arg1, arg2)+      _ ->+        -- We add 1 because, technically, the tag is a stanza argument too.+        Left $ ParseScryptStanzaInvalidNumberOfArgumentsError 3 (length sArgs + 1)++  salt <-+    case decodeBase64StdUnpadded saltB64 of+      Left err -> Left (ParseScryptStanzaSaltBase64DecodingError err)+      Right bs ->+        case bytesToSalt bs of+          Nothing -> Left (ParseScryptStanzaInvalidSaltSizeError 16 (BS.length bs))+          Just salt -> Right salt++  workFactor <-+    case parseOnly workFactorParser workFactorBs of+      Left err -> Left (ParseScryptStanzaWorkFactorParseError err)+      Right wf -> Right wf++  unless (actualBodyLength == expectedBodyLength) $ Left (ParseScryptStanzaInvalidBodySizeError expectedBodyLength actualBodyLength)++  Right $+    ScryptRecipientStanza+      { srsSalt = salt+      , srsWorkFactor = workFactor+      , srsEncryptedFileKey = sBody+      }+  where+    Stanza+      { sTag+      , sArgs+      , sBody+      } = s++    expectedBodyLength :: Int+    expectedBodyLength = 32++    actualBodyLength :: Int+    actualBodyLength = BS.length sBody++-- | Convert an 'ScryptRecipientStanza' to a 'Stanza'.+fromScryptRecipientStanza :: ScryptRecipientStanza -> Stanza+fromScryptRecipientStanza s =+  Stanza+    { sTag = scryptStanzaTag+    , sArgs =+        [ encodeBase64StdUnpadded (saltToBytes srsSalt)+        , BS.toStrict (Builder.toLazyByteString $ workFactorBuilder srsWorkFactor)+        ]+    , sBody = srsEncryptedFileKey+    }+  where+    ScryptRecipientStanza+      { srsSalt+      , srsWorkFactor+      , srsEncryptedFileKey+      } = s++-- | [X25519 recipient stanza](https://github.com/C2SP/C2SP/blob/34a9210873230d2acaa4a4c9c5d4d1119b2ee77d/age.md#x25519-recipient-stanza).+data X25519RecipientStanza = X25519RecipientStanza+  { -- | Sender's ephemeral Curve25519 public key.+    --+    -- Referred to as the \"ephemeral share\" in the age specification.+    xrsSenderPublicKey :: !Curve25519.PublicKey+  , -- | Encrypted file key.+    xrsEncryptedFileKey :: !ByteString+  } deriving stock (Show, Eq)++-- | Error wrapping a file key in an X25519 recipient stanza.+data WrapX25519StanzaFileKeyError+  = -- | DH shared secret is an all-zero value.+    WrapX25519StanzaFileKeyAllZeroSharedSecretError+  deriving stock (Show, Eq)++-- | Wrap a 'FileKey' in an 'X25519RecipientStanza'.+wrapFileKeyForX25519Recipient ::+  X25519Recipient ->+  X25519Identity ->+  FileKey ->+  Either WrapX25519StanzaFileKeyError X25519RecipientStanza+wrapFileKeyForX25519Recipient (X25519Recipient recipientPk) (X25519Identity senderSk) fk = do+  let senderPk :: Curve25519.PublicKey+      senderPk = Curve25519.toPublic senderSk++      salt :: ByteString+      salt = BA.convert senderPk <> BA.convert recipientPk++      info :: ByteString+      info = "age-encryption.org/v1/X25519"++      zeroSharedSecret :: Curve25519.DhSecret+      zeroSharedSecret = Crypto.throwCryptoError $ Curve25519.dhSecret (BA.replicate 32 0x00 :: ScrubbedBytes)++      sharedSecret :: Curve25519.DhSecret+      sharedSecret = Curve25519.dh recipientPk senderSk++  when (sharedSecret == zeroSharedSecret) (Left WrapX25519StanzaFileKeyAllZeroSharedSecretError)++  let prk :: HKDF.PRK Crypto.SHA256+      prk = HKDF.extract salt sharedSecret++      wrapKey :: ScrubbedBytes+      wrapKey = HKDF.expand prk info 32++      nonce :: ChaCha20Poly1305.Nonce+      nonce = Crypto.throwCryptoError $ ChaCha20Poly1305.nonce12 (BS.replicate 12 0x00)++      st :: ChaCha20Poly1305.State+      st = Crypto.throwCryptoError $ ChaCha20Poly1305.initialize wrapKey nonce++      ciphertext :: ByteString+      st2 :: ChaCha20Poly1305.State+      (ciphertext, st2) = ChaCha20Poly1305.encrypt (BA.convert $ fileKeyToBytes fk) st++      authTag :: Poly1305.Auth+      authTag = ChaCha20Poly1305.finalize st2++  Right $+    X25519RecipientStanza+      { xrsSenderPublicKey = senderPk+      , xrsEncryptedFileKey = ciphertext <> BA.convert authTag+      }++-- | X25519 recipient stanza tag (i.e. the first stanza argument).+--+-- According to the+-- [age specification](https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#x25519-recipient-stanza),+-- this is expected to be the string, @X25519@.+x25519StanzaTag :: ByteString+x25519StanzaTag = "X25519"++-- | Error converting a 'Stanza' to an 'X25519RecipientStanza'.+data ParseX25519StanzaError+  = -- | Invalid tag.+    ParseX25519StanzaInvalidTagError+      -- | Expected tag.+      !ByteString+      -- | Actual tag.+      !ByteString+  | -- | Invalid number of arguments.+    ParseX25519StanzaInvalidNumberOfArgumentsError+      -- | Expected number of arguments.+      !Int+      -- | Actual number of arguments.+      !Int+  | -- | Error decoding the sender's ephemeral public key from base64.+    ParseX25519StanzaEphemeralShareBase64DecodingError+      -- | Base64 decoding error.+      !Text+  | -- | Invalid ephemeral share.+    ParseX25519StanzaInvalidEphemeralShareError+      -- | Error that occurred.+      !Crypto.CryptoError+      -- | Invalid ephemeral share bytes.+      !ByteString+  | -- | Invalid stanza body size.+    ParseX25519StanzaInvalidBodySizeError+      -- | Expected body size in bytes.+      !Int+      -- | Actual body size in bytes.+      !Int+  deriving stock (Show, Eq)++-- | Convert a 'Stanza' to an 'X25519RecipientStanza'.+toX25519RecipientStanza :: Stanza -> Either ParseX25519StanzaError X25519RecipientStanza+toX25519RecipientStanza s = do+  unless (sTag == x25519StanzaTag) $ Left (ParseX25519StanzaInvalidTagError x25519StanzaTag sTag)++  ephemeralShareB64 <-+    case sArgs of+      [arg] -> Right arg+      _ ->+        -- We add 1 because, technically, the tag is a stanza argument too.+        Left $ ParseX25519StanzaInvalidNumberOfArgumentsError 2 (length sArgs + 1)++  ephemeralShareBs <-+    case decodeBase64StdUnpadded ephemeralShareB64 of+      Left err -> Left (ParseX25519StanzaEphemeralShareBase64DecodingError err)+      Right bs -> Right bs++  ephemeralShare <-+    case Curve25519.publicKey ephemeralShareBs of+      Crypto.CryptoFailed err -> Left (ParseX25519StanzaInvalidEphemeralShareError err ephemeralShareBs)+      Crypto.CryptoPassed pk -> Right pk++  unless (actualBodyLength == expectedBodyLength) $ Left (ParseX25519StanzaInvalidBodySizeError expectedBodyLength actualBodyLength)++  Right $+    X25519RecipientStanza+      { xrsSenderPublicKey = ephemeralShare+      , xrsEncryptedFileKey = sBody+      }+  where+    Stanza+      { sTag+      , sArgs+      , sBody+      } = s++    expectedBodyLength :: Int+    expectedBodyLength = 32++    actualBodyLength :: Int+    actualBodyLength = BS.length sBody++-- | Convert an 'X25519RecipientStanza' to a 'Stanza'.+fromX25519RecipientStanza :: X25519RecipientStanza -> Stanza+fromX25519RecipientStanza s =+  Stanza+    { sTag = x25519StanzaTag+    , sArgs = [encodeBase64StdUnpadded (BA.convert ephemeralShare)]+    , sBody = xrsEncryptedFileKey+    }+  where+    X25519RecipientStanza+      { xrsSenderPublicKey = ephemeralShare+      , xrsEncryptedFileKey+      } = s
+ src/Crypto/Age/Scrypt.hs view
@@ -0,0 +1,116 @@+{-# LANGUAGE PatternSynonyms #-}++-- | @scrypt@-related types and utilities.+module Crypto.Age.Scrypt+  ( -- * Passphrase+    Passphrase (..)++    -- * Salt+  , Salt (Salt)+  , bytesToSalt+  , saltToBytes++    -- * Work factor+  , WorkFactor (WorkFactor)+  , unWorkFactor+  , mkWorkFactor+  , workFactorBuilder+  , workFactorParser+  ) where++import Control.Monad ( when )+import Data.Attoparsec.ByteString ( Parser, endOfInput, peekWord8' )+import Data.Attoparsec.ByteString.Char8 ( decimal )+import Data.ByteArray ( ScrubbedBytes )+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import Data.ByteString.Builder ( Builder )+import qualified Data.ByteString.Builder as Builder+import Data.Word ( Word8 )+import Prelude++-- | @scrypt@ passphrase.+--+-- Note that this type's 'Eq' instance performs a constant-time equality+-- check.+newtype Passphrase = Passphrase+  { unPassphrase :: ScrubbedBytes }+  deriving newtype (Show, Eq)++-- | @scrypt@ salt.+newtype Salt = MkSalt+  { unSalt :: ByteString }+  deriving newtype (Show, Eq)++pattern Salt :: ByteString -> Salt+pattern Salt bs <- MkSalt bs++{-# COMPLETE Salt #-}++-- | Construct a 'Salt' from bytes.+--+-- If the provided byte string does not have a length of 16 (128 bits),+-- 'Nothing' is returned.+bytesToSalt :: ByteString -> Maybe Salt+bytesToSalt bs+  | BS.length bs == 16 = Just (MkSalt bs)+  | otherwise = Nothing++-- | Get the raw bytes associated with a 'Salt'.+saltToBytes :: Salt -> ByteString+saltToBytes = unSalt++-- | Minimum work factor (@1@).+minWorkFactor :: Word8+minWorkFactor = 1++-- | Maximum work factor (@64@).+maxWorkFactor :: Word8+maxWorkFactor = 64++-- | @scrypt@ \"work factor\" (as it is referred to in the age specification).+--+-- This value is used in calculating the+-- [@scrypt@ cost parameter (also referred to as @N@)](https://www.rfc-editor.org/rfc/rfc7914#section-2):+--+-- > N = 2 ^ work_factor+newtype WorkFactor = MkWorkFactor+  { unWorkFactor :: Word8 }+  deriving newtype (Show, Eq)++instance Bounded WorkFactor where+  minBound = MkWorkFactor minWorkFactor+  maxBound = MkWorkFactor maxWorkFactor++pattern WorkFactor :: Word8 -> WorkFactor+pattern WorkFactor w8 <- MkWorkFactor w8++{-# COMPLETE WorkFactor #-}++-- | Construct a 'WorkFactor' value.+--+-- If the provided value is @0@ or greater than @64@, this function will return+-- 'Nothing'.+mkWorkFactor :: Word8 -> Maybe WorkFactor+mkWorkFactor w8+  | w8 >= minWorkFactor && w8 <= maxWorkFactor = Just (MkWorkFactor w8)+  | otherwise = Nothing++-- | 'WorkFactor' encoder.+workFactorBuilder :: WorkFactor -> Builder+workFactorBuilder = Builder.word8Dec . unWorkFactor++-- | 'WorkFactor' parser.+workFactorParser :: Parser WorkFactor+workFactorParser = do+  firstByte <- peekWord8'+  when (firstByte == 0x30) $+    -- Leading zeroes are disallowed+    fail "expected digit from 1 to 9"++  parsedDigits <- decimal <* endOfInput :: Parser Integer+  when (parsedDigits <= 0) $+    fail "expected integer greater than 0"+  when (parsedDigits > 64) $+    fail "expected integer less than 65"+  pure $ MkWorkFactor (fromIntegral parsedDigits)
+ src/Data/Attoparsec/ByteString/Base64.hs view
@@ -0,0 +1,35 @@+module Data.Attoparsec.ByteString.Base64+  ( isBase64Char+  , base64CharParser+  , takeNBase64Chars+  , takeMNBase64Chars+  ) where++import Data.Attoparsec.ByteString ( Parser, count, inClass, satisfy, (<?>) )+import Data.Attoparsec.ByteString.Extra ( takeWhileMN )+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import Data.Word ( Word8 )+import Prelude++-- | Determine whether a 'Word8' is a valid character in an unpadded base64+-- string.+isBase64Char :: Word8 -> Bool+isBase64Char = inClass "a-zA-Z0-9+/"++-- | Parse a base64 character.+base64CharParser :: Parser Word8+base64CharParser = satisfy isBase64Char <?> "base64 character"++-- | Consume exactly @n@ base64 characters.+takeNBase64Chars :: Word -> Parser ByteString+takeNBase64Chars n = BS.pack <$> count (fromIntegral n) base64CharParser++-- | Consume a base64 character string of length @m <= len <= n@.+takeMNBase64Chars ::+  -- | @m@.+  Word ->+  -- | @n@.+  Word ->+  Parser ByteString+takeMNBase64Chars m n = takeWhileMN m n isBase64Char
+ src/Data/Attoparsec/ByteString/Extra.hs view
@@ -0,0 +1,84 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE LambdaCase #-}++module Data.Attoparsec.ByteString.Extra+  ( takeWhileMN+  , countMN+  ) where++import Control.Applicative ( optional )+import Control.Monad ( MonadPlus )+import Data.Attoparsec.ByteString ( Parser, scan )+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import Data.Word ( Word8 )+import Prelude++-- | Consume the longest (@m <= len <= n@) input slice where the predicate+-- returns 'True', and return the consumed input.+--+-- This parser fails in the event that the length of its consumed input does+-- not satisfy @m <= len <= n@.+takeWhileMN ::+  -- | @m@.+  Word ->+  -- | @n@.+  Word ->+  -- | Predicate.+  (Word8 -> Bool) ->+  Parser ByteString+takeWhileMN m n f+  | m > n = fail "takeWhileMN: m cannot be greater than n"+  | otherwise = do+      bs <- scan 0 transformState+      let len = BS.length bs+      if mI <= len && nI >= len+        then pure bs+        else+          fail $+            "takeWhileMN: consumed input length ("+              <> show len+              <> ") must be >= "+              <> show mI+              <> " and <= "+              <> show nI+              <> "."+  where+    mI :: Int+    mI = fromIntegral m++    nI :: Int+    nI = fromIntegral n++    -- Parse up to @n@ bytes where the predicate returns 'True'.+    transformState :: Word -> Word8 -> Maybe Word+    transformState s b+      | s == n = Nothing+      | s < n && f b = Just (s + 1)+      | otherwise = Nothing++-- | Applies from @m@ to @n@ occurrences of @p@. Returns a list of the returned+-- values of @p@. The value returned by @p@ is forced to WHNF.+countMN :: MonadPlus m => Word -> Word -> m a -> m [a]+countMN m n p+  | m > n = error "countMN: m cannot be greater than n"+  | n == 0 = pure []+  | otherwise = reverse <$> goUntilM []+  where+    mI :: Int+    mI = fromIntegral m++    nI :: Int+    nI = fromIntegral n++    goUntilM !acc+      | length acc == mI = goUntilN acc+      | otherwise = do+          !x <- p+          goUntilM (x : acc)++    goUntilN !acc+      | length acc == nI = pure acc+      | otherwise = optional p >>= \case+          Nothing -> pure acc+          Just !x -> goUntilN (x : acc)
+ src/Data/Binary/Put/Integer.hs view
@@ -0,0 +1,27 @@+module Data.Binary.Put.Integer+  ( putIntegerbe+  ) where++import Data.Binary.Put ( Put, putByteString )+import Data.Bits ( shiftR )+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import Data.Word ( Word8 )+import Prelude++-- | Encode an 'Integer' to a 'ByteString' as big endian.+--+-- Ripped from [haskoin-core-1.1.0](https://hackage.haskell.org/package/haskoin-core-1.1.0/docs/src/Haskoin.Util.Helpers.html#integerToBS).+integerToBS :: Integer -> ByteString+integerToBS 0 = BS.pack [0]+integerToBS i+  | i > 0 = BS.reverse $ BS.unfoldr f i+  | otherwise = error "integerToBS not defined for negative values"+  where+    f 0 = Nothing+    f x = Just (fromInteger x :: Word8, x `shiftR` 8)++-- | Write an 'Integer' in big endian format+putIntegerbe :: Integer -> Put+putIntegerbe = putByteString . integerToBS+{-# INLINE putIntegerbe #-}
+ src/Data/ByteString/Base64/Extra.hs view
@@ -0,0 +1,40 @@+{-# LANGUAGE CPP #-}++module Data.ByteString.Base64.Extra+  ( encodeBase64StdUnpadded+  , decodeBase64StdUnpadded+  ) where++#if MIN_VERSION_base64(1,0,0)+import qualified Data.Base64.Types as B64+#endif+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import qualified Data.ByteString.Base64 as B64+import qualified Data.ByteString.Base64.URL as B64URL+import Data.Text ( Text )+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import Prelude++encodeBase64StdUnpadded :: ByteString -> ByteString+encodeBase64StdUnpadded =+  -- Drop padding bytes ('=') from the end of the base64 string.+  (BS.dropWhileEnd (== 0x3D))+#if MIN_VERSION_base64(1,0,0)+    . B64.extractBase64+#endif+    . B64.encodeBase64'++decodeBase64StdUnpadded :: ByteString -> Either Text ByteString+decodeBase64StdUnpadded b64 =+  -- The @base64@ library does not support decoding unpadded standard base64.+  -- So we're going to convert to base64url and then decode.+  let b64Url :: ByteString+      b64Url = TE.encodeUtf8 (T.replace "+" "-" $ T.replace "/" "_" $ TE.decodeUtf8 b64)+  in+#if MIN_VERSION_base64(1,0,0)+    B64URL.decodeBase64UnpaddedUntyped b64Url+#else+    B64URL.decodeBase64Unpadded b64Url+#endif
+ src/Data/ByteString/Extra.hs view
@@ -0,0 +1,16 @@+module Data.ByteString.Extra+  ( chunksOf+  ) where++import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import Prelude++chunksOf :: Int -> ByteString -> [ByteString]+chunksOf k = go+  where+    go t =+      case BS.splitAt k t of+        (a, b)+          | BS.null a -> []+          | otherwise -> a : go b
+ src/Data/Conduit/Base64.hs view
@@ -0,0 +1,87 @@+{-# LANGUAGE CPP #-}++module Data.Conduit.Base64+  ( conduitEncodeBase64+  , conduitDecodeBase64+  ) where++import Control.Exception ( assert )+import Control.Monad.Except ( ExceptT, throwError )+import Control.Monad.Trans.Class ( MonadTrans (lift) )+import Data.Text ( Text )+#if MIN_VERSION_base64(1,0,0)+import qualified Data.Base64.Types as B64+#endif+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import qualified Data.ByteString.Base64 as B64+import Data.Conduit ( ConduitT, await, yield )+import Data.MonoTraversable ( olength )+import Prelude++conduitEncodeBase64 :: Monad m => ConduitT ByteString ByteString m ()+conduitEncodeBase64 =+  codeWith 3+#if MIN_VERSION_base64(1,0,0)+    (Right . B64.extractBase64 . B64.encodeBase64')+#else+    (Right . B64.encodeBase64')+#endif+    (error "conduitEncodeBase64: impossible: base64 encoding cannot fail")++conduitDecodeBase64 :: Monad m => ConduitT ByteString ByteString (ExceptT Text m) ()+conduitDecodeBase64 =+  codeWith 4+#if MIN_VERSION_base64(1,0,0)+    B64.decodeBase64Untyped+#else+    B64.decodeBase64+#endif+    (lift . throwError)++codeWith ::+  Monad m =>+  Int ->+  -- | Encoding or decoding function.+  (ByteString -> Either e ByteString) ->+  -- | Error handling function.+  (e -> ConduitT ByteString ByteString m ()) ->+  ConduitT ByteString ByteString m ()+codeWith size f handleErr =+    loop+  where+    loop = await >>= maybe (return ()) push++    loopWith bs+        | BS.null bs = loop+        | otherwise = await >>= maybe (finish bs) (pushWith bs)++    finish bs =+        case f bs of+            Left err -> handleErr err+            Right x -> yield x++    push bs = do+        let (x, y) = BS.splitAt (len - (len `mod` size)) bs+        if BS.null x+            then loopWith y+            else do+                case f x of+                    Left err -> handleErr err+                    Right x' -> yield x' >> loopWith y+      where+        len = olength bs++    pushWith bs1 bs2 | BS.length bs1 + BS.length bs2 < size = loopWith (BS.append bs1 bs2)+    pushWith bs1 bs2 = assertion1 $ assertion2 $ assertion3 $+        case f bs1' of+            Left err -> handleErr err+            Right toYield -> yield toYield >> push y+      where+        m = BS.length bs1 `mod` size+        (x, y) = BS.splitAt (size - m) bs2+        bs1' = mappend bs1 x++        assertion1 = assert $ olength bs1 < size+        assertion2 = assert $ olength bs1' `mod` size == 0+        assertion3 = assert $ olength bs1' > 0
+ src/Data/List/Compat.hs view
@@ -0,0 +1,55 @@+{-# LANGUAGE CPP #-}++module Data.List.Compat+  ( unsnoc+  ) where++#if (MIN_VERSION_base(4,19,0))+import Data.List ( unsnoc )+#else+import Prelude+#endif+++#if !(MIN_VERSION_base(4,19,0))+-- | \(\mathcal{O}(n)\). Decompose a list into 'init' and 'last'.+--+-- * If the list is empty, returns 'Nothing'.+-- * If the list is non-empty, returns @'Just' (xs, x)@,+-- where @xs@ is the 'init'ial part of the list and @x@ is its 'last' element.+--+--+-- 'unsnoc' is dual to 'uncons': for a finite list @xs@+--+-- > unsnoc xs = (\(hd, tl) -> (reverse tl, hd)) <$> uncons (reverse xs)+--+-- ==== __Examples__+--+-- >>> unsnoc []+-- Nothing+--+-- >>> unsnoc [1]+-- Just ([],1)+--+-- >>> unsnoc [1, 2, 3]+-- Just ([1,2],3)+--+-- ==== __Laziness__+--+-- >>> fst <$> unsnoc [undefined]+-- Just []+--+-- >>> head . fst <$> unsnoc (1 : undefined)+-- Just *** Exception: Prelude.undefined+--+-- >>> head . fst <$> unsnoc (1 : 2 : undefined)+-- Just 1+--+-- @since base-4.19.0.0+unsnoc :: [a] -> Maybe ([a], a)+-- The lazy pattern ~(a, b) is important to be productive on infinite lists+-- and not to be prone to stack overflows.+-- Expressing the recursion via 'foldr' provides for list fusion.+unsnoc = foldr (\x -> Just . maybe ([], x) (\(~(a, b)) -> (x : a, b))) Nothing+{-# INLINABLE unsnoc #-}+#endif
+ test/Main.hs view
@@ -0,0 +1,25 @@+module Main where++import Hedgehog.Main ( defaultMain )+import Prelude+import qualified Test.Crypto.Age.Armor+import qualified Test.Crypto.Age.Conduit+import qualified Test.Crypto.Age.Header+import qualified Test.Crypto.Age.Identity+import qualified Test.Crypto.Age.Key+import qualified Test.Crypto.Age.Recipient+import qualified Test.Crypto.Age.Scrypt+import qualified Test.Crypto.Age.TestVector++main :: IO ()+main =+  defaultMain+    [ Test.Crypto.Age.Armor.tests+    , Test.Crypto.Age.Conduit.tests+    , Test.Crypto.Age.Header.tests+    , Test.Crypto.Age.Identity.tests+    , Test.Crypto.Age.Key.tests+    , Test.Crypto.Age.Recipient.tests+    , Test.Crypto.Age.Scrypt.tests+    , Test.Crypto.Age.TestVector.tests+    ]
+ test/Test/Crypto/Age/Armor.hs view
@@ -0,0 +1,87 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE TemplateHaskell #-}++module Test.Crypto.Age.Armor+  ( tests+  ) where++import Control.Monad.Except ( runExceptT )+import Crypto.Age.Armor ( ArmorError (..), conduitArmor, conduitUnarmor )+import qualified Data.Conduit as C+import qualified Data.Conduit.Combinators as C+import qualified Data.Conduit.List as CL+import Data.Functor.Identity ( runIdentity )+import Hedgehog+  ( Property+  , annotateShow+  , checkParallel+  , discover+  , forAll+  , property+  , withTests+  , (===)+  )+import qualified Hedgehog.Gen as Gen+import qualified Hedgehog.Range as Range+import Prelude++tests :: IO Bool+tests = checkParallel $$(discover)++------------------------------------------------------------------------------+-- Properties+------------------------------------------------------------------------------++-- | Test that 'conduitArmor' and 'conduitUnarmor' round trip.+prop_roundTrip_conduitArmorUnarmor :: Property+prop_roundTrip_conduitArmorUnarmor = property $ do+  expected <- forAll $ Gen.bytes (Range.linear 1 1024)+  let sourceExpected = C.yield expected++  let encodedRes =+        runIdentity . runExceptT . C.runConduit $+          sourceExpected+            C..| conduitArmor+            C..| C.fold+  annotateShow encodedRes+  encoded <-+    case encodedRes of+      Left err -> fail $ "failed to armor: " <> show err+      Right x -> pure x++  let actualRes =+        runIdentity . runExceptT . C.runConduit $+          C.yield encoded+            C..| conduitUnarmor+            C..| C.fold+  actual <-+    case actualRes of+      Left err -> fail $ "failed to unarmor: " <> show err+      Right x -> pure x++  expected === actual++-- | Test that 'conduitArmor' fails when no data is pushed from upstream.+prop_conduitArmorFailsWithNullSource :: Property+prop_conduitArmorFailsWithNullSource = withTests 1 . property $ do+  let res =+        runIdentity . runExceptT . C.runConduit $+          CL.sourceNull+            C..| conduitArmor+            C..| C.fold+  case res of+    Left ArmorNoDataError -> pure ()+    Right _ -> fail $ "expected " <> show ArmorNoDataError <> " but got a success result"++-- | Test that 'conduitArmor' fails when only an empty 'ByteString' is pushed+-- from upstream.+prop_conduitArmorFailsWithEmptyByteString :: Property+prop_conduitArmorFailsWithEmptyByteString = withTests 1 . property $ do+  let res =+        runIdentity . runExceptT . C.runConduit $+          C.yield ""+            C..| conduitArmor+            C..| C.fold+  case res of+    Left ArmorNoDataError -> pure ()+    Right _ -> fail $ "expected " <> show ArmorNoDataError <> " but got a success result"
+ test/Test/Crypto/Age/Conduit.hs view
@@ -0,0 +1,156 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE TemplateHaskell #-}++module Test.Crypto.Age.Conduit+  ( tests+  ) where++import Crypto.Age.Conduit+  ( RecipientEncryptionParams (..)+  , conduitEncryptEitherPure+  , decryptPayloadChunk+  , encryptPayloadChunk+  , sinkDecryptEither+  )+import Crypto.Age.Identity+  ( Identity (..), ScryptIdentity (..), toX25519Recipient )+import Crypto.Age.Recipient ( Recipients (..), ScryptRecipient (..) )+import Crypto.Age.Scrypt ( WorkFactor (..) )+import qualified Data.ByteString as BS+import qualified Data.Conduit as C+import qualified Data.Conduit.List as CL+import qualified Data.List.NonEmpty as NE+import Hedgehog+  ( Gen+  , Property+  , annotateShow+  , checkParallel+  , discover+  , forAll+  , forAllWith+  , property+  , tripping+  , (===)+  )+import qualified Hedgehog.Gen as Gen+import qualified Hedgehog.Range as Range+import Prelude+import Test.Crypto.Age.Identity.Gen ( genScryptIdentity, genX25519Identity )+import Test.Crypto.Age.Key.Gen ( genFileKey, genPayloadKey, genPayloadKeyNonce )+import Test.Crypto.Age.Key.Render+  ( unsafeRenderFileKey, unsafeRenderPayloadKey )+import Test.Crypto.Age.Payload.Counter.Gen ( genPayloadChunkCounter )+import Test.Crypto.Age.Payload.Plaintext.Gen ( genPlaintextPayloadChunk )+import Test.Crypto.Age.Recipient.Gen ( genX25519Recipients )+import Test.Crypto.Age.Scrypt.Gen ( genSalt, genWorkFactorInRange )++tests :: IO Bool+tests = checkParallel $$(discover)++------------------------------------------------------------------------------+-- Properties+------------------------------------------------------------------------------++-- | Test that 'encryptPayloadChunk' and 'decryptPayloadChunk' round trip.+prop_roundTrip_encryptDecryptPayloadChunk :: Property+prop_roundTrip_encryptDecryptPayloadChunk = property $ do+  payloadKey <- forAllWith unsafeRenderPayloadKey genPayloadKey+  counter <- forAll genPayloadChunkCounter+  plaintext <- forAll genPlaintextPayloadChunk+  tripping+    plaintext+    (encryptPayloadChunk payloadKey counter)+    (decryptPayloadChunk payloadKey counter)++-- | Test that 'conduitEncryptEitherPure' (pure variant of 'conduitEncrypt')+-- and 'conduitDecryptEither' round trip.+prop_roundTrip_conduitEncryptDecrypt :: Property+prop_roundTrip_conduitEncryptDecrypt = property $ do+  (senderId, recipients) <- forAll genSenderIdentityAndRecipients+  recipientParams <- forAll (genRecipientEncryptionParams recipients)+  fileKey <- forAllWith unsafeRenderFileKey genFileKey+  payloadKeyNonce <- forAll genPayloadKeyNonce+  expectedPlaintext <- forAll $ Gen.maybe $ Gen.bytes (Range.constant 0 1024)+  let sourcePlaintext =+        case expectedPlaintext of+          Just bs -> C.yield bs+          Nothing ->+            -- Testing the case where no plaintext is streamed.+            CL.sourceNull++  let ciphertextRes =+        C.runConduitPure $+          sourcePlaintext+            C..| sinkEncryptPure recipientParams fileKey payloadKeyNonce+  annotateShow ciphertextRes+  ciphertext <-+    case ciphertextRes of+      Left err -> fail $ "failed to encrypt plaintext: " <> show err+      Right c -> pure c++  let actualPlaintextRes =+        C.runConduitPure $+          C.yield ciphertext+            C..| sinkDecryptEither (NE.singleton senderId)+  actualPlaintext <-+    case actualPlaintextRes of+      Left err -> fail $ "failed to decrypt ciphertext: " <> show err+      Right p -> pure p++  case expectedPlaintext of+    Just bs -> bs === actualPlaintext+    Nothing ->+      -- In the case where we didn't stream any plaintext, we should've still+      -- created a valid age file where an empty byte string was encrypted.+      BS.empty === actualPlaintext+  where+    sinkEncryptPure recipientParams fileKey payloadKeyNonce =+      conduitEncryptEitherPure recipientParams fileKey payloadKeyNonce+        C..| go mempty+      where+        go acc = C.await >>= \case+          Nothing -> pure (Right acc)+          Just (Left err) -> pure (Left err)+          Just (Right bs) -> go (acc <> bs)++------------------------------------------------------------------------------+-- Generators+------------------------------------------------------------------------------++genSenderIdentityAndRecipients :: Gen (Identity, Recipients)+genSenderIdentityAndRecipients =+  Gen.choice+    [ genSenderScryptIdentityAndRecipients+    , genSenderX25519IdentityAndRecipients+    ]+  where+    genSenderScryptIdentityAndRecipients :: Gen (Identity, Recipients)+    genSenderScryptIdentityAndRecipients = do+      identity@ScryptIdentity+        { siPassphrase+        , siMaxWorkFactor = WorkFactor maxWorkFactor+        } <- genScryptIdentity+      salt <- genSalt+      workFactor <- genWorkFactorInRange (Range.constant 1 maxWorkFactor)+      let recipient =+            ScryptRecipient+              { srPassphrase = siPassphrase+              , srSalt = salt+              , srWorkFactor = workFactor+              }+      pure (IdentityScrypt identity, RecipientsScrypt recipient)++    genSenderX25519IdentityAndRecipients :: Gen (Identity, Recipients)+    genSenderX25519IdentityAndRecipients = do+      senderId <- genX25519Identity+      let senderRecip = toX25519Recipient senderId+      moreRecipients <- genX25519Recipients 10+      pure (IdentityX25519 senderId, RecipientsX25519 (NE.singleton senderRecip <> moreRecipients))++genRecipientEncryptionParams :: Recipients -> Gen RecipientEncryptionParams+genRecipientEncryptionParams recipients =+  case recipients of+    RecipientsScrypt r -> pure (RecipientEncryptionParamsScrypt r)+    RecipientsX25519 rs ->+      RecipientEncryptionParamsX25519+        <$> mapM (\r -> (,) r <$> genX25519Identity) rs
+ test/Test/Crypto/Age/Header.hs view
@@ -0,0 +1,119 @@+{-# LANGUAGE TemplateHaskell #-}++module Test.Crypto.Age.Header+  ( tests+  ) where++import Crypto.Age.Header+  ( Header (..)+  , HeaderMac (..)+  , Stanza (..)+  , headerBuilder+  , headerMacBuilder+  , headerMacParser+  , headerParser+  , stanzaBuilder+  , stanzaParser+  )+import qualified Crypto.Hash as Crypto+import qualified Crypto.MAC.HMAC as Crypto+import Data.Attoparsec.ByteString ( parseOnly )+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import Data.ByteString.Builder ( Builder, toLazyByteString )+import qualified Data.List.NonEmpty as NE+import Hedgehog+  ( Property, checkParallel, discover, forAll, property, tripping )+import Prelude+import Test.Crypto.Age.Header.Gen ( genHeader, genHeaderMac, genStanza )+import Test.Golden ( goldenTestWithEncoderAndDecoder )++tests :: IO Bool+tests = checkParallel $$(discover)++------------------------------------------------------------------------------+-- Properties+------------------------------------------------------------------------------++-- | Test that 'stanzaBuilder' and 'stanzaParser' round trip.+prop_roundTrip_encodeParseStanza :: Property+prop_roundTrip_encodeParseStanza = property $ do+  stanza <- forAll genStanza+  tripping+    stanza+    (toStrictByteString . stanzaBuilder)+    (parseOnly stanzaParser)++-- | Test that 'headerMacBuilder' and 'headerMacParser' round trip.+prop_roundTrip_encodeParseHeaderMac :: Property+prop_roundTrip_encodeParseHeaderMac = property $ do+  headerMac <- forAll genHeaderMac+  tripping+    headerMac+    (toStrictByteString . headerMacBuilder)+    (parseOnly headerMacParser)++-- | Test that 'headerBuilder' and 'headerParser' round trip.+prop_roundTrip_encodeParseHeader :: Property+prop_roundTrip_encodeParseHeader = property $ do+  header <- forAll genHeader+  tripping+    header+    (toStrictByteString . headerBuilder)+    (parseOnly headerParser)++prop_golden_stanza :: Property+prop_golden_stanza =+  goldenTestWithEncoderAndDecoder+    (toStrictByteString . stanzaBuilder)+    (parseOnly stanzaParser)+    goldenStanza+    "test/golden/binary/stanza/golden.bin"++prop_golden_headerMac :: Property+prop_golden_headerMac =+  goldenTestWithEncoderAndDecoder+    (toStrictByteString . headerMacBuilder)+    (parseOnly headerMacParser)+    goldenHeaderMac+    "test/golden/binary/header-mac/golden.bin"++prop_golden_header :: Property+prop_golden_header =+  goldenTestWithEncoderAndDecoder+    (toStrictByteString . headerBuilder)+    (parseOnly headerParser)+    goldenHeader+    "test/golden/binary/header/golden.bin"++------------------------------------------------------------------------------+-- Golden examples+------------------------------------------------------------------------------++goldenStanza :: Stanza+goldenStanza =+  Stanza+    { sTag = "tag"+    , sArgs = ["arg1", "arg2", "arg3", "aaaaaaaaaaaaaaaaaa"]+    , sBody = BS.replicate 1024 0x41+    }++goldenHeaderMac :: HeaderMac+goldenHeaderMac =+  case Crypto.digestFromByteString (BS.replicate 32 0x41) of+    Nothing -> error "goldenHeaderMac: impossible: could not construct HMAC"+    Just digest -> HeaderMac (Crypto.HMAC digest)++goldenHeader :: Header+goldenHeader =+  Header+    { hStanzas = goldenStanza NE.:| replicate 10 goldenStanza+    , hMac = goldenHeaderMac+    }++------------------------------------------------------------------------------+-- Helpers+------------------------------------------------------------------------------++toStrictByteString :: Builder -> ByteString+toStrictByteString = BS.toStrict . toLazyByteString
+ test/Test/Crypto/Age/Header/Gen.hs view
@@ -0,0 +1,51 @@+module Test.Crypto.Age.Header.Gen+  ( genStanza+  , genStanzas+  , genHeaderMac+  , genHeader+  ) where++import Crypto.Age.Header ( Header (..), HeaderMac (..), Stanza (..) )+import qualified Crypto.Hash as Crypto+import qualified Crypto.MAC.HMAC as Crypto+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import Data.List.NonEmpty ( NonEmpty )+import qualified Data.List.NonEmpty as NE+import Data.Word ( Word8 )+import Hedgehog ( Gen, Range )+import qualified Hedgehog.Gen as Gen+import qualified Hedgehog.Range as Range+import Prelude++genVchar :: Gen Word8+genVchar = Gen.word8 (Range.constant 0x21 0x7E)++genVcharString :: Range Int -> Gen ByteString+genVcharString range = BS.pack <$> Gen.list range genVchar++genStanza :: Gen Stanza+genStanza =+  Stanza+    <$> genVcharString (Range.constant 1 256)+    <*> Gen.list (Range.constant 0 10) (genVcharString (Range.constant 1 256))+    <*> Gen.bytes (Range.constant 0 1024)++genStanzas ::+  -- | Upper bound (inclusive) on number of stanzas to generate.+  Int ->+  Gen (NonEmpty Stanza)+genStanzas x = NE.fromList <$> Gen.list (Range.constant 1 x) genStanza++genHeaderMac :: Gen HeaderMac+genHeaderMac = do+  bs <- Gen.bytes (Range.singleton 32)+  case HeaderMac . Crypto.HMAC <$> Crypto.digestFromByteString bs of+    Nothing -> fail "failed to generate a HeaderMac"+    Just x -> pure x++genHeader :: Gen Header+genHeader =+  Header+    <$> genStanzas 10+    <*> genHeaderMac
+ test/Test/Crypto/Age/Identity.hs view
@@ -0,0 +1,82 @@+{-# LANGUAGE TemplateHaskell #-}++module Test.Crypto.Age.Identity+  ( tests+  ) where++import Crypto.Age.Identity+  ( X25519Identity+  , bytesToX25519Identity+  , decodeX25519Identity+  , encodeX25519Identity+  , x25519IdentityToBytes+  )+import qualified Data.ByteArray as BA+import Data.Text ( Text )+import qualified Data.Text.Encoding as TE+import Hedgehog+  ( Property, checkParallel, discover, forAll, property, tripping )+import Prelude+import Test.Crypto.Age.Identity.Gen ( genX25519Identity )+import Test.Golden ( goldenTestWithEncoderAndDecoder )++tests :: IO Bool+tests = checkParallel $$(discover)++------------------------------------------------------------------------------+-- Properties+------------------------------------------------------------------------------++-- | Test that 'bytesToX25519Identity' and 'x25519IdentityToBytes' round trip.+prop_roundTrip_x25519IdentityBytes :: Property+prop_roundTrip_x25519IdentityBytes = property $ do+  identity <- forAll genX25519Identity+  tripping+    identity+    x25519IdentityToBytes+    bytesToX25519Identity++-- | Test that 'encodeX25519Identity' and 'decodeX25519Identity' round trip.+prop_roundTrip_encodeDecodeX25519Identity :: Property+prop_roundTrip_encodeDecodeX25519Identity = property $ do+  identity <- forAll genX25519Identity+  tripping+    identity+    unsafeEncodeX25519Identity+    decodeX25519Identity++prop_golden_x25519Identity_bytes :: Property+prop_golden_x25519Identity_bytes =+  goldenTestWithEncoderAndDecoder+    (BA.convert . x25519IdentityToBytes)+    (bytesToX25519Identity . BA.convert)+    goldenX25519Identity+    "test/golden/binary/x25519-identity/golden.bin"++prop_golden_x25519Identity_bech32 :: Property+prop_golden_x25519Identity_bech32 =+  goldenTestWithEncoderAndDecoder+    (TE.encodeUtf8 . unsafeEncodeX25519Identity)+    (decodeX25519Identity . TE.decodeUtf8)+    goldenX25519Identity+    "test/golden/bech32/x25519-identity/golden.txt"++------------------------------------------------------------------------------+-- Golden examples+------------------------------------------------------------------------------++goldenX25519Identity :: X25519Identity+goldenX25519Identity =+  case bytesToX25519Identity (BA.replicate 32 0x41) of+    Nothing -> error "goldenX25519Identity: impossible: could not construct X25519Identity"+    Just x -> x++------------------------------------------------------------------------------+-- Helpers+------------------------------------------------------------------------------++unsafeEncodeX25519Identity :: X25519Identity -> Text+unsafeEncodeX25519Identity i =+  case encodeX25519Identity i of+    Left err -> error $ "unsafeEncodeX25519Identity: impossible: error encoding X25519Identity: " <> show err+    Right t -> t
+ test/Test/Crypto/Age/Identity/Gen.hs view
@@ -0,0 +1,38 @@+module Test.Crypto.Age.Identity.Gen+  ( genScryptIdentity+  , genX25519Identity+  , genIdentity+  ) where++import Crypto.Age.Identity+  ( Identity (..)+  , ScryptIdentity (..)+  , X25519Identity (..)+  , bytesToX25519Identity+  )+import Hedgehog ( Gen )+import qualified Hedgehog.Gen as Gen+import qualified Hedgehog.Range as Range+import Prelude+import Test.Crypto.Age.Scrypt.Gen ( genPassphrase, genWorkFactor )+import Test.Gen ( genByteArray )++genScryptIdentity :: Gen ScryptIdentity+genScryptIdentity =+  ScryptIdentity+    <$> genPassphrase+    <*> genWorkFactor++genX25519Identity :: Gen X25519Identity+genX25519Identity = do+  bs <- genByteArray (Range.singleton 32)+  case bytesToX25519Identity bs of+    Nothing -> fail "failed to generate a X25519Identity"+    Just x -> pure x++genIdentity :: Gen Identity+genIdentity =+  Gen.choice+    [ IdentityScrypt <$> genScryptIdentity+    , IdentityX25519 <$> genX25519Identity+    ]
+ test/Test/Crypto/Age/Key.hs view
@@ -0,0 +1,118 @@+{-# LANGUAGE TemplateHaskell #-}++module Test.Crypto.Age.Key+  ( tests+  ) where++import Crypto.Age.Key+  ( FileKey (..)+  , PayloadKey (..)+  , bytesToFileKey+  , bytesToPayloadKey+  , bytesToPayloadKeyNonce+  , fileKeyToBytes+  , payloadKeyNonceToBytes+  , payloadKeyToBytes+  )+import qualified Data.ByteArray as BA+import Hedgehog+  ( Property, checkParallel, discover, forAll, forAllWith, property, tripping )+import qualified Hedgehog.Gen as Gen+import qualified Hedgehog.Range as Range+import Prelude+import Test.Crypto.Age.Key.Gen ( genFileKey, genPayloadKey, genPayloadKeyNonce )+import Test.Crypto.Age.Key.Render+  ( unsafeRenderFileKey, unsafeRenderPayloadKey )++tests :: IO Bool+tests = checkParallel $$(discover)++------------------------------------------------------------------------------+-- Properties+------------------------------------------------------------------------------++-- | Test that 'bytesToFileKey' only returns 'Just' when given 16 bytes.+prop_bytesToFileKey :: Property+prop_bytesToFileKey = property $ do+  bs <- forAll $ BA.convert <$> Gen.bytes (Range.constant 0 256)+  let bsLen = BA.length bs+  case bytesToFileKey bs of+    Nothing+      | bsLen == 16 -> fail "failed when given 16 bytes"+      | otherwise -> pure ()+    Just _+      | bsLen == 16 -> pure ()+      | otherwise -> fail $ "succeeded when given " <> show bsLen <> " bytes"++-- | Test that 'fileKeyToBytes' and 'bytesToFileKey' round trip.+prop_roundTrip_fileKeyBytes :: Property+prop_roundTrip_fileKeyBytes = property $ do+  fk <- forAllWith unsafeRenderFileKey genFileKey+  tripping+    (ShowableFileKey fk)+    (fileKeyToBytes . unShowableFileKey)+    ((ShowableFileKey <$>) . bytesToFileKey)++-- | Test that 'bytesToPayloadKeyNonce' only returns 'Just' when given 16 bytes.+prop_bytesToPayloadKeyNonce :: Property+prop_bytesToPayloadKeyNonce = property $ do+  bs <- forAll $ BA.convert <$> Gen.bytes (Range.constant 0 256)+  let bsLen = BA.length bs+  case bytesToPayloadKeyNonce bs of+    Nothing+      | bsLen == 16 -> fail "failed when given 16 bytes"+      | otherwise -> pure ()+    Just _+      | bsLen == 16 -> pure ()+      | otherwise -> fail $ "succeeded when given " <> show bsLen <> " bytes"++-- | Test that 'payloadKeyNonceToBytes' and 'bytesToPayloadKeyNonce' round+-- trip.+prop_roundTrip_payloadKeyNonceBytes :: Property+prop_roundTrip_payloadKeyNonceBytes = property $ do+  pkn <- forAll genPayloadKeyNonce+  tripping+    pkn+    payloadKeyNonceToBytes+    bytesToPayloadKeyNonce++-- | Test that 'bytesToPayloadKey' only returns 'Just' when given 32 bytes.+prop_bytesToPayloadKey :: Property+prop_bytesToPayloadKey = property $ do+  bs <- forAll $ BA.convert <$> Gen.bytes (Range.constant 0 256)+  let bsLen = BA.length bs+  case bytesToPayloadKey bs of+    Nothing+      | bsLen == 32 -> fail "failed when given 32 bytes"+      | otherwise -> pure ()+    Just _+      | bsLen == 32 -> pure ()+      | otherwise -> fail $ "succeeded when given " <> show bsLen <> " bytes"++-- | Test that 'payloadKeyToBytes' and 'bytesToPayloadKey' round+-- trip.+prop_roundTrip_payloadKeyBytes :: Property+prop_roundTrip_payloadKeyBytes = property $ do+  pk <- forAllWith unsafeRenderPayloadKey genPayloadKey+  tripping+    (ShowablePayloadKey pk)+    (payloadKeyToBytes . unShowablePayloadKey)+    ((ShowablePayloadKey <$>) . bytesToPayloadKey)++------------------------------------------------------------------------------+-- Helpers+------------------------------------------------------------------------------++newtype ShowableFileKey = ShowableFileKey+  { unShowableFileKey :: FileKey }+  deriving newtype (Eq)++instance Show ShowableFileKey where+  show = unsafeRenderFileKey . unShowableFileKey++newtype ShowablePayloadKey = ShowablePayloadKey+  { unShowablePayloadKey :: PayloadKey }+  deriving newtype (Eq)++instance Show ShowablePayloadKey where+  show = unsafeRenderPayloadKey . unShowablePayloadKey
+ test/Test/Crypto/Age/Key/Gen.hs view
@@ -0,0 +1,40 @@+module Test.Crypto.Age.Key.Gen+  ( genFileKey+  , genPayloadKeyNonce+  , genPayloadKey+  ) where++import Crypto.Age.Key+  ( FileKey+  , PayloadKey+  , PayloadKeyNonce+  , bytesToFileKey+  , bytesToPayloadKey+  , bytesToPayloadKeyNonce+  )+import Hedgehog ( Gen )+import qualified Hedgehog.Gen as Gen+import qualified Hedgehog.Range as Range+import Prelude+import Test.Gen ( genByteArray )++genFileKey :: Gen FileKey+genFileKey = do+  bs <- genByteArray (Range.singleton 16)+  case bytesToFileKey bs of+    Nothing -> fail "failed to generate a FileKey"+    Just x -> pure x++genPayloadKeyNonce :: Gen PayloadKeyNonce+genPayloadKeyNonce = do+  bs <- Gen.bytes (Range.singleton 16)+  case bytesToPayloadKeyNonce bs of+    Nothing -> fail "failed to generate a PayloadKeyNonce"+    Just x -> pure x++genPayloadKey :: Gen PayloadKey+genPayloadKey = do+  bs <- genByteArray (Range.singleton 32)+  case bytesToPayloadKey bs of+    Nothing -> fail "failed to generate a PayloadKey"+    Just x -> pure x
+ test/Test/Crypto/Age/Key/Render.hs view
@@ -0,0 +1,17 @@+module Test.Crypto.Age.Key.Render+  ( unsafeRenderFileKey+  , unsafeRenderPayloadKey+  ) where++import Crypto.Age.Key ( FileKey (..), PayloadKey (..) )+import qualified Data.ByteArray as BA+import Data.ByteString ( ByteString )+import Prelude++unsafeRenderFileKey :: FileKey -> String+unsafeRenderFileKey (FileKey bs) =+  show (BA.convert bs :: ByteString)++unsafeRenderPayloadKey :: PayloadKey -> String+unsafeRenderPayloadKey (PayloadKey bs) =+  show (BA.convert bs :: ByteString)
+ test/Test/Crypto/Age/Payload/Counter/Gen.hs view
@@ -0,0 +1,17 @@+module Test.Crypto.Age.Payload.Counter.Gen+  ( genPayloadChunkCounter+  ) where++import Crypto.Age.Payload.Counter+  ( PayloadChunkCounter, maxPayloadChunkCounter, mkPayloadChunkCounter )+import Hedgehog ( Gen )+import qualified Hedgehog.Gen as Gen+import qualified Hedgehog.Range as Range+import Prelude++genPayloadChunkCounter :: Gen PayloadChunkCounter+genPayloadChunkCounter = do+  i <- Gen.integral (Range.constant 0 maxPayloadChunkCounter)+  case mkPayloadChunkCounter i of+    Nothing -> fail "failed to generate a PayloadChunkCounter"+    Just x -> pure x
+ test/Test/Crypto/Age/Payload/Plaintext/Gen.hs view
@@ -0,0 +1,47 @@+module Test.Crypto.Age.Payload.Plaintext.Gen+  ( genNormalPlaintextPayloadChunk+  , genFinalPlaintextPayloadChunk+  , genPlaintextPayloadChunk+  ) where++import Crypto.Age.Payload.Plaintext+  ( FinalPlaintextPayloadChunk+  , NormalPlaintextPayloadChunk+  , PlaintextPayloadChunk (..)+  , mkFinalPlaintextPayloadChunk+  , mkNormalPlaintextPayloadChunk+  , plaintextChunkSize+  )+import Hedgehog ( Gen )+import qualified Hedgehog.Gen as Gen+import qualified Hedgehog.Range as Range+import Prelude+import Test.Gen ( genByteArray )++genNormalPlaintextPayloadChunk :: Gen NormalPlaintextPayloadChunk+genNormalPlaintextPayloadChunk = do+  bs <- genByteArray (Range.singleton plaintextChunkSize)+  case mkNormalPlaintextPayloadChunk bs of+    Nothing -> fail "failed to generate a NormalPlaintextPayloadChunk"+    Just x -> pure x++genFinalPlaintextPayloadChunk :: Gen FinalPlaintextPayloadChunk+genFinalPlaintextPayloadChunk = do+  bs <-+    -- Tend more toward generating smaller chunks.+    Gen.frequency+      [ (1, genByteArray (Range.constant 256 plaintextChunkSize))+      , (30, genByteArray (Range.constant 0 256))+      ]+  case mkFinalPlaintextPayloadChunk bs of+    Nothing -> fail "failed to generate a FinalPlaintextPayloadChunk"+    Just x -> pure x++genPlaintextPayloadChunk :: Gen PlaintextPayloadChunk+genPlaintextPayloadChunk =+  -- Tend more toward generating smaller final chunks rather than full 64 KiB+  -- normal chunks.+  Gen.frequency+    [ (1, PlaintextPayloadChunkNormal <$> genNormalPlaintextPayloadChunk)+    , (30, PlaintextPayloadChunkFinal <$> genFinalPlaintextPayloadChunk)+    ]
+ test/Test/Crypto/Age/Recipient.hs view
@@ -0,0 +1,83 @@+{-# LANGUAGE TemplateHaskell #-}++module Test.Crypto.Age.Recipient+  ( tests+  , goldenX25519Recipient+  ) where++import Crypto.Age.Recipient+  ( X25519Recipient+  , bytesToX25519Recipient+  , decodeX25519Recipient+  , encodeX25519Recipient+  , x25519RecipientToBytes+  )+import qualified Data.ByteArray as BA+import Data.Text ( Text )+import qualified Data.Text.Encoding as TE+import Hedgehog+  ( Property, checkParallel, discover, forAll, property, tripping )+import Prelude+import Test.Crypto.Age.Recipient.Gen ( genX25519Recipient )+import Test.Golden ( goldenTestWithEncoderAndDecoder )++tests :: IO Bool+tests = checkParallel $$(discover)++------------------------------------------------------------------------------+-- Properties+------------------------------------------------------------------------------++-- | Test that 'bytesToX25519Recipient' and 'x25519RecipientToBytes' round trip.+prop_roundTrip_x25519RecipientBytes :: Property+prop_roundTrip_x25519RecipientBytes = property $ do+  recipient <- forAll genX25519Recipient+  tripping+    recipient+    x25519RecipientToBytes+    bytesToX25519Recipient++-- | Test that 'encodeX25519Recipient' and 'decodeX25519Recipient' round trip.+prop_roundTrip_encodeDecodeX25519Recipient :: Property+prop_roundTrip_encodeDecodeX25519Recipient = property $ do+  recipient <- forAll genX25519Recipient+  tripping+    recipient+    unsafeEncodeX25519Recipient+    decodeX25519Recipient++prop_golden_x25519Recipient_bytes :: Property+prop_golden_x25519Recipient_bytes =+  goldenTestWithEncoderAndDecoder+    (BA.convert . x25519RecipientToBytes)+    (bytesToX25519Recipient . BA.convert)+    goldenX25519Recipient+    "test/golden/binary/x25519-recipient/golden.bin"++prop_golden_x25519Recipient_bech32 :: Property+prop_golden_x25519Recipient_bech32 =+  goldenTestWithEncoderAndDecoder+    (TE.encodeUtf8 . unsafeEncodeX25519Recipient)+    (decodeX25519Recipient . TE.decodeUtf8)+    goldenX25519Recipient+    "test/golden/bech32/x25519-recipient/golden.txt"++------------------------------------------------------------------------------+-- Golden examples+------------------------------------------------------------------------------++goldenX25519Recipient :: X25519Recipient+goldenX25519Recipient =+  case bytesToX25519Recipient (BA.replicate 32 0x41) of+    Nothing -> error "goldenX25519Recipient: impossible: could not construct X25519Recipient"+    Just x -> x++------------------------------------------------------------------------------+-- Helpers+------------------------------------------------------------------------------++unsafeEncodeX25519Recipient :: X25519Recipient -> Text+unsafeEncodeX25519Recipient i =+  case encodeX25519Recipient i of+    Left err -> error $ "unsafeEncodeX25519Recipient: impossible: error encoding X25519Recipient: " <> show err+    Right t -> t
+ test/Test/Crypto/Age/Recipient/Gen.hs view
@@ -0,0 +1,45 @@+module Test.Crypto.Age.Recipient.Gen+  ( genScryptRecipient+  , genX25519Recipient+  , genX25519Recipients+  , genRecipients+  ) where++import Crypto.Age.Recipient+  ( Recipients (..), ScryptRecipient (..), X25519Recipient (..) )+import qualified Crypto.Error as Crypto+import qualified Crypto.PubKey.Curve25519 as Crypto+import Data.List.NonEmpty ( NonEmpty )+import qualified Data.List.NonEmpty as NE+import Hedgehog ( Gen )+import qualified Hedgehog.Gen as Gen+import qualified Hedgehog.Range as Range+import Prelude+import Test.Crypto.Age.Scrypt.Gen ( genPassphrase, genSalt, genWorkFactor )++genScryptRecipient :: Gen ScryptRecipient+genScryptRecipient =+  ScryptRecipient+    <$> genPassphrase+    <*> genSalt+    <*> genWorkFactor++genX25519Recipient :: Gen X25519Recipient+genX25519Recipient = do+  bs <- Gen.bytes (Range.singleton 32)+  case Crypto.eitherCryptoError (Crypto.publicKey bs) of+    Left err -> fail $ "failed to generate a X25519Recipient: " <> show err+    Right x -> pure $ X25519Recipient x++genX25519Recipients ::+  -- | Upper bound (inclusive) on number of recipients to generate.+  Int ->+  Gen (NonEmpty X25519Recipient)+genX25519Recipients x = NE.fromList <$> Gen.list (Range.constant 1 x) genX25519Recipient++genRecipients :: Gen Recipients+genRecipients =+  Gen.choice+    [ RecipientsScrypt <$> genScryptRecipient+    , RecipientsX25519 <$> genX25519Recipients 10+    ]
+ test/Test/Crypto/Age/Scrypt.hs view
@@ -0,0 +1,135 @@+{-# LANGUAGE TemplateHaskell #-}++module Test.Crypto.Age.Scrypt+  ( tests+  , goldenWorkFactor+  ) where++import Crypto.Age.Scrypt+  ( WorkFactor+  , bytesToSalt+  , mkWorkFactor+  , saltToBytes+  , workFactorBuilder+  , workFactorParser+  )+import Data.Attoparsec.ByteString ( parseOnly )+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import Data.ByteString.Builder ( Builder, toLazyByteString )+import qualified Data.ByteString.Builder as Builder+import Data.Word ( Word8 )+import Hedgehog+  ( Property, checkParallel, discover, forAll, property, tripping )+import qualified Hedgehog.Gen as Gen+import qualified Hedgehog.Range as Range+import Prelude+import Test.Crypto.Age.Scrypt.Gen ( genSalt, genWorkFactorInRange )+import Test.Golden ( goldenTestWithEncoderAndDecoder )++tests :: IO Bool+tests = checkParallel $$(discover)++------------------------------------------------------------------------------+-- Properties+------------------------------------------------------------------------------++-- | Test that 'bytesToSalt' only returns 'Just' when given 16 bytes.+prop_bytesToSalt :: Property+prop_bytesToSalt = property $ do+  bs <- forAll $ Gen.bytes (Range.constant 0 256)+  let bsLen = BS.length bs+  case bytesToSalt bs of+    Nothing+      | bsLen == 16 -> fail "failed when given 16 bytes"+      | otherwise -> pure ()+    Just _+      | bsLen == 16 -> pure ()+      | otherwise -> fail $ "succeeded when given " <> show bsLen <> " bytes"++-- | Test that 'saltToBytes' and 'bytesToSalt' round trip.+prop_roundTrip_saltBytes :: Property+prop_roundTrip_saltBytes = property $ do+  salt <- forAll genSalt+  tripping+    salt+    saltToBytes+    bytesToSalt++-- | Test that 'mkWorkFactor' only returns 'Just' when given an integer+-- @0 < n <= 64@.+prop_mkWorkFactor :: Property+prop_mkWorkFactor = property $ do+  n <- forAll $ Gen.word8 Range.constantBounded+  case mkWorkFactor n of+    Nothing+      | isValid n -> fail $ "failed when given " <> show n+      | otherwise -> pure ()+    Just _+      | isValid n -> pure ()+      | otherwise -> fail $ "succeeded when given " <> show n+  where+    isValid :: Word8 -> Bool+    isValid w8+      | w8 >= 1 && w8 <= 64 = True+      | otherwise = False++-- | Test that 'workFactorBuilder' and 'workFactorParser' round trip.+prop_roundTrip_encodeParseWorkFactor :: Property+prop_roundTrip_encodeParseWorkFactor = property $ do+  workFactor <- forAll $ genWorkFactorInRange (Range.constant 1 64)+  tripping+    workFactor+    (toStrictByteString . workFactorBuilder)+    (parseOnly workFactorParser)++-- | Test that 'workFactorParser' fails when parsing a work factor with leading+-- zeroes.+prop_workFactorParser_failOnLeadingZeroes :: Property+prop_workFactorParser_failOnLeadingZeroes = property $ do+  workFactor <- forAll $ genWorkFactorInRange (Range.constant 1 64)+  let encodedWorkFactor = toStrictByteString (workFactorBuilder workFactor)+  leadingZeroes <- forAll $ Gen.utf8 (Range.constant 1 256) (pure '0')+  let encodedWorkFactorWithLeadingZeroes = leadingZeroes <> encodedWorkFactor+  case parseOnly workFactorParser encodedWorkFactorWithLeadingZeroes of+    Left _ -> pure ()+    Right _ -> fail $ "succeeded when parsing " <> show encodedWorkFactorWithLeadingZeroes++-- | Test that 'workFactorParser' fails when parsing a work factor that is @0@+-- or greater than @64@.+prop_workFactorParser_cannotParseIntOutOfRange :: Property+prop_workFactorParser_cannotParseIntOutOfRange = property $ do+  n <- forAll $ Gen.word Range.linearBounded+  let encoded = toStrictByteString (Builder.wordDec n)+  case parseOnly workFactorParser encoded of+    Left _+      | n <= 0 || n > 64 -> pure ()+      | otherwise -> fail $ "failed when parsing " <> show n+    Right _+      | n > 0 && n <= 64 -> pure ()+      | otherwise -> fail $ "succeeded when parsing " <> show n++prop_golden_workFactor :: Property+prop_golden_workFactor =+  goldenTestWithEncoderAndDecoder+    (toStrictByteString . workFactorBuilder)+    (parseOnly workFactorParser)+    goldenWorkFactor+    "test/golden/binary/work-factor/golden.bin"++------------------------------------------------------------------------------+-- Golden examples+------------------------------------------------------------------------------++goldenWorkFactor :: WorkFactor+goldenWorkFactor =+  case mkWorkFactor 13 of+    Nothing -> error "goldenWorkFactor: impossible: could not construct work factor"+    Just x -> x++------------------------------------------------------------------------------+-- Helpers+------------------------------------------------------------------------------++toStrictByteString :: Builder -> ByteString+toStrictByteString = BS.toStrict . toLazyByteString
+ test/Test/Crypto/Age/Scrypt/Gen.hs view
@@ -0,0 +1,45 @@+module Test.Crypto.Age.Scrypt.Gen+  ( genPassphrase+  , genSalt+  , genWorkFactorInRange+  , genWorkFactor+  ) where++import Crypto.Age.Scrypt+  ( Passphrase (..), Salt, WorkFactor, bytesToSalt, mkWorkFactor )+import Data.Word ( Word8 )+import Hedgehog ( Gen, Range )+import qualified Hedgehog.Gen as Gen+import qualified Hedgehog.Range as Range+import Prelude+import Test.Gen ( genByteArray )++genPassphrase :: Gen Passphrase+genPassphrase = Passphrase <$> genByteArray (Range.constant 0 64)++genSalt :: Gen Salt+genSalt = do+  bs <- genByteArray (Range.singleton 16)+  case bytesToSalt bs of+    Nothing -> fail "failed to generate a Salt"+    Just x -> pure x++-- | Generate a 'WorkFactor' in the specified range.+--+-- Note that you should take care when generating large 'WorkFactor's as it can+-- cause @scrypt@ to commit large amounts of memory.+--+-- Also note that this generator can fail if you provide a 'Range' outside the+-- interval @[1, 64]@ (a 'WorkFactor' can be constructed with a value in this+-- range).+genWorkFactorInRange :: Range Word8 -> Gen WorkFactor+genWorkFactorInRange range = do+  i <- Gen.word8 range+  case mkWorkFactor i of+    Nothing -> fail "failed to generate a WorkFactor"+    Just x -> pure x++genWorkFactor :: Gen WorkFactor+genWorkFactor =+  -- We limit the work factor to @10@ for testing.+  genWorkFactorInRange (Range.constant 1 10)
+ test/Test/Crypto/Age/TestVector.hs view
@@ -0,0 +1,25 @@+module Test.Crypto.Age.TestVector+  ( tests+  ) where++import Conduit ( runResourceT )+import Control.Monad.IO.Class ( MonadIO (..) )+import Control.Monad.Morph ( MFunctor (hoist) )+import Data.String ( fromString )+import Hedgehog ( Group (..), checkParallel, property, withDiscards, withTests )+import Prelude+import Test.Crypto.Age.TestVector.Property ( mkTestVectorProperties )++tests :: IO Bool+tests = testVectors >>= checkParallel++------------------------------------------------------------------------------+-- Properties+------------------------------------------------------------------------------++testVectors :: MonadIO m => m Group+testVectors = do+  props <- liftIO mkTestVectorProperties+  pure $ Group "Test Vectors (https://age-encryption.org/testkit)" $+    flip map props $ \(propName, prop) ->+      (fromString propName, withTests 1 . withDiscards 1 . property $ hoist runResourceT prop)
+ test/Test/Crypto/Age/TestVector/Header.hs view
@@ -0,0 +1,246 @@+{-# LANGUAGE LambdaCase #-}++module Test.Crypto.Age.TestVector.Header+  ( Expect (..)+  , Compressed (..)+  , Header (..)+  , headerParser+  ) where++import Control.Monad ( void )+import Crypto.Age.Identity+  ( Identity (..), ScryptIdentity (..), X25519Identity, decodeX25519Identity )+import Crypto.Age.Scrypt ( Passphrase (..), WorkFactor (..), mkWorkFactor )+import qualified Crypto.Hash as Crypto+import Data.Attoparsec.ByteString+  ( Parser, many1', notInClass, skipWhile, takeTill, takeWhile )+import Data.Attoparsec.ByteString.Char8+  ( char8, endOfLine, isEndOfLine, isHorizontalSpace )+import Data.Base16.Types ( Base16, assertBase16 )+import qualified Data.ByteArray as BA+import Data.ByteString ( ByteString )+import Data.ByteString.Base16 ( decodeBase16Untyped, isValidBase16 )+import Data.Foldable ( foldl' )+import Data.Text ( Text )+import qualified Data.Text.Encoding as TE+import Data.Word ( Word8 )+import Prelude hiding ( takeWhile )++data Expect+  = ExpectSuccess+  | ExpectNoMatch+  | ExpectHmacFailure+  | ExpectHeaderFailure+  | ExpectPayloadFailure+  | ExpectArmorFailure+  deriving stock (Show)++parseExpect :: ByteString -> Maybe Expect+parseExpect = \case+  "success" -> Just ExpectSuccess+  "no match" -> Just ExpectNoMatch+  "HMAC failure" -> Just ExpectHmacFailure+  "header failure" -> Just ExpectHeaderFailure+  "payload failure" -> Just ExpectPayloadFailure+  "armor failure" -> Just ExpectArmorFailure+  _ -> Nothing++data Compressed+  = CompressedGzip+  | CompressedZlib+  deriving stock (Show)++parseCompressed :: ByteString -> Maybe Compressed+parseCompressed = \case+  "gzip" -> Just CompressedGzip+  "zlib" -> Just CompressedZlib+  _ -> Nothing++data RawHeaderLine = RawHeaderLine+  { rhlName :: !ByteString+  , rhlValue :: !ByteString+  }++rawHeaderLineParser :: Parser RawHeaderLine+rawHeaderLineParser =+  RawHeaderLine+    <$> (takeWhile isToken <* char8 ':' <* skipWhile isHorizontalSpace)+    <*> (takeTill isEndOfLine <* endOfLine)+  where+    isToken :: Word8 -> Bool+    isToken w = w <= 127 && notInClass "\0-\31()<>@,;:\\\"/[]?={}\t\127" w++data HeaderLine+  = HeaderLineExpect !Expect+  | HeaderLineCompressed !Compressed+  | HeaderLinePayload !(Crypto.Digest Crypto.SHA256)+  | HeaderLineIdentity !X25519Identity+  | HeaderLinePassphrase !Passphrase+  | HeaderLineArmored !Bool+  | HeaderLineFileKey !(Base16 ByteString)+  | HeaderLineComment !Text++fromRawHeaderLine :: RawHeaderLine -> Maybe HeaderLine+fromRawHeaderLine r =+  case rhlName of+    "expect" -> HeaderLineExpect <$> parseExpect rhlValue+    "compressed" -> HeaderLineCompressed <$> parseCompressed rhlValue+    "payload" -> do+      decoded <- eitherToMaybe (decodeBase16Untyped rhlValue)+      HeaderLinePayload <$> (Crypto.digestFromByteString decoded)+    "identity" -> HeaderLineIdentity <$> eitherToMaybe (decodeX25519Identity $ TE.decodeUtf8 rhlValue)+    "passphrase" -> Just . HeaderLinePassphrase . Passphrase $ BA.convert rhlValue+    "armored" ->+      Just $+        case rhlValue of+          "yes" -> HeaderLineArmored True+          _ -> HeaderLineArmored False+    "file key" ->+      if isValidBase16 rhlValue+        then Just $ HeaderLineFileKey (assertBase16 rhlValue)+        else Nothing+    "comment" -> Just $ HeaderLineComment (TE.decodeUtf8 rhlValue)+    _ -> Nothing+  where+    RawHeaderLine+      { rhlName+      , rhlValue+      } = r++    eitherToMaybe :: Either a b -> Maybe b+    eitherToMaybe (Left _) = Nothing+    eitherToMaybe (Right a) = Just a++headerLineParser :: Parser HeaderLine+headerLineParser = do+  line@RawHeaderLine{rhlName, rhlValue} <- rawHeaderLineParser+  case fromRawHeaderLine line of+    Just x -> pure x+    Nothing ->+      fail $+        "failed to parse header line with name "+          <> show (TE.decodeUtf8 rhlName)+          <> " and value: "+          <> show (TE.decodeUtf8 rhlValue)++newtype HeaderLines = HeaderLines+  { unHeaderLines :: [HeaderLine] }++headerLinesParser :: Parser HeaderLines+headerLinesParser = HeaderLines <$> many1' headerLineParser++data HeaderOptions = HeaderOptions+  { hoExpect :: !(Maybe Expect)+  , hoCompressed :: !(Maybe Compressed)+  , hoPayload :: !(Maybe (Crypto.Digest Crypto.SHA256))+  , hoIdentity :: !(Maybe Identity)+  , hoArmored :: !(Maybe Bool)+  , hoFileKey :: !(Maybe (Base16 ByteString))+  , hoComment :: !(Maybe Text)+  }++emptyHeaderOptions :: HeaderOptions+emptyHeaderOptions =+  HeaderOptions+    { hoExpect = Nothing+    , hoCompressed = Nothing+    , hoPayload = Nothing+    , hoIdentity = Nothing+    , hoArmored = Nothing+    , hoFileKey = Nothing+    , hoComment = Nothing+    }++toHeaderOptions :: HeaderLines -> HeaderOptions+toHeaderOptions = foldl' accumHeaderOptions emptyHeaderOptions . unHeaderLines+  where+    -- The test vector, @scrypt_work_factor_23@, expects us to fail given an+    -- scrypt stanza with a work factor of 23.+    --+    -- So we just set our maximum to 22 in order to ensure that we return the+    -- expected error.+    workFactor22 :: WorkFactor+    workFactor22 =+      case mkWorkFactor 22 of+        Just x -> x+        Nothing -> error "toHeaderOptions: impossible: could not construct work factor of 22"++    accumHeaderOptions :: HeaderOptions -> HeaderLine -> HeaderOptions+    accumHeaderOptions acc line =+      case line of+        HeaderLineExpect x -> acc { hoExpect = Just x }+        HeaderLineCompressed x -> acc { hoCompressed = Just x }+        HeaderLinePayload x -> acc { hoPayload = Just x }+        HeaderLineIdentity x -> acc { hoIdentity = Just (IdentityX25519 x) }+        HeaderLinePassphrase x -> acc { hoIdentity = Just $ IdentityScrypt (ScryptIdentity x workFactor22) }+        HeaderLineArmored x -> acc { hoArmored = Just x }+        HeaderLineFileKey x -> acc { hoFileKey = Just x }+        HeaderLineComment x -> acc { hoComment = Just x }++data Header = Header+  { hExpect :: !Expect+  , hCompressed :: !(Maybe Compressed)+  , hPayload :: !(Maybe (Crypto.Digest Crypto.SHA256))+  , hIdentity :: !Identity+  , hArmored :: !Bool+  , hFileKey :: !(Maybe (Base16 ByteString))+  , hComment :: !(Maybe Text)+  }++toHeader :: HeaderOptions -> Maybe Header+toHeader h =+  case hoExpect of+    Just expect ->+      Just $+        Header+          { hExpect = expect+          , hCompressed = hoCompressed+          , hPayload = hoPayload+          , hIdentity =+              case hoIdentity of+                Just identity -> identity+                Nothing ->+                  -- There are a couple test vectors where neither an X25519+                  -- identity nor scrypt passphrase are specified:+                  --+                  -- - armor_empty+                  -- - empty+                  --+                  -- These are expected to fail parsing due to being empty, so+                  -- we'll just default to some invalid identity since it+                  -- doesn't matter.+                  defaultIdentity+          , hArmored =+              case hoArmored of+                Just x -> x+                _ -> False+          , hFileKey = hoFileKey+          , hComment = hoComment+          }+    Nothing -> Nothing+  where+    HeaderOptions+      { hoExpect+      , hoCompressed+      , hoPayload+      , hoIdentity+      , hoArmored+      , hoFileKey+      , hoComment+      } = h++    defaultIdentity :: Identity+    defaultIdentity =+      IdentityScrypt $+        ScryptIdentity+          { siPassphrase = Passphrase "aaaa"+          , siMaxWorkFactor = maxBound+          }++headerParser :: Parser Header+headerParser = do+  headerLines <- headerLinesParser+  void endOfLine+  case toHeader (toHeaderOptions headerLines) of+    Just x -> pure x+    Nothing -> fail "Header is missing required fields"
+ test/Test/Crypto/Age/TestVector/Property.hs view
@@ -0,0 +1,116 @@+module Test.Crypto.Age.TestVector.Property+  ( mkTestVectorProperties+  ) where++import Conduit ( ResourceT )+import Control.Monad ( filterM )+import Control.Monad.Except ( ExceptT, withExceptT )+import Crypto.Age.Armor ( UnarmorError (..), conduitUnarmor )+import Crypto.Age.Conduit+  ( DecryptError (..), DecryptPayloadError (..), sinkDecrypt )+import qualified Crypto.Hash as Crypto+import Data.ByteString ( ByteString )+import Data.Conduit+  ( ConduitT, awaitForever, transPipe, yield, ($$+), ($$+-), (.|) )+import Data.Conduit.Attoparsec ( sinkParser )+import qualified Data.Conduit.Combinators as C+import Data.Conduit.Lift ( runExceptC )+import qualified Data.Conduit.Zlib as Zlib+import Data.Foldable ( for_ )+import Data.List ( sort )+import qualified Data.List.NonEmpty as NE+import qualified Data.Text as T+import Hedgehog ( PropertyT, footnote, (===) )+import Prelude+import System.Directory ( doesFileExist, listDirectory )+import Test.Crypto.Age.TestVector.Header+  ( Compressed (..), Expect (..), Header (..), headerParser )++testVectorDirectory :: FilePath+testVectorDirectory = "test/test-vectors"++getTestVectorFileNames :: IO [FilePath]+getTestVectorFileNames = do+  fileNames <- sort <$> listDirectory testVectorDirectory+  flip filterM fileNames $ \fileName ->+    doesFileExist $ concat [testVectorDirectory, "/", fileName]++data UnarmorOrDecryptError+  = UnarmorOrDecryptUnarmorError !UnarmorError+  | UnarmorOrDecryptDecryptError !DecryptError+  deriving stock (Show)++mkTestVectorProperty :: FilePath -> (String, PropertyT (ResourceT IO) ())+mkTestVectorProperty fileName = (fileName, prop)+  where+    conduitUnarmorIfExpected :: Monad m => Bool -> ConduitT ByteString ByteString (ExceptT UnarmorError m) ()+    conduitUnarmorIfExpected isArmored+      | isArmored = conduitUnarmor+      | otherwise = awaitForever yield++    prop :: PropertyT (ResourceT IO) ()+    prop = do+      let path = concat [testVectorDirectory, "/", fileName]+      (sealedSrc, header) <- C.sourceFile path $$+ sinkParser headerParser+      let Header+            { hExpect+            , hCompressed+            , hPayload+            , hIdentity+            , hArmored+            , hComment+            } = header+      for_ hComment (\comment -> footnote . T.unpack $ "test comment: " <> comment)+      let conduitDecompress =+            case hCompressed of+              Just CompressedGzip -> Zlib.ungzip+              Just CompressedZlib -> Zlib.decompress Zlib.defaultWindowBits+              Nothing -> awaitForever yield++      res <-+        sealedSrc+          $$+-+            ( runExceptC $+                conduitDecompress+                  .| transPipe (withExceptT UnarmorOrDecryptUnarmorError) (conduitUnarmorIfExpected hArmored)+                  .| transPipe (withExceptT UnarmorOrDecryptDecryptError) (sinkDecrypt (NE.singleton hIdentity))+            )+      case (hExpect, res) of+        (ExpectSuccess, Right actualPayload) ->+          case hPayload of+            Just expectedPayloadDigest -> expectedPayloadDigest === Crypto.hash actualPayload+            Nothing -> fail "expected payload field in test vector file header"+        (ExpectNoMatch, Left (UnarmorOrDecryptDecryptError DecryptNoMatchingRecipientError)) -> pure ()+        (ExpectHmacFailure, Left (UnarmorOrDecryptDecryptError (DecryptInvalidHeaderMacError _ _))) -> pure ()+        (ExpectHeaderFailure, Left (UnarmorOrDecryptDecryptError (DecryptHeaderParseError _))) -> pure ()+        (ExpectHeaderFailure, Left (UnarmorOrDecryptDecryptError DecryptScryptStanzaNotAloneError)) -> pure ()+        (ExpectHeaderFailure, Left (UnarmorOrDecryptDecryptError (DecryptUnwrapStanzaError _))) -> pure ()+        (ExpectHeaderFailure, Left (UnarmorOrDecryptDecryptError (DecryptDecryptPayloadError (DecryptPayloadKeyNonceParseError _)))) ->+          -- This is considered a header failure according to some of the test+          -- vectors; which is a bit odd since the payload nonce is explicitly+          -- described as being part of the payload:+          --+          -- > The binary payload... It begins with a 16-byte nonce generated+          -- > by the sender...+          --+          -- https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#payload+          pure ()+        (ExpectHeaderFailure, Left (UnarmorOrDecryptDecryptError DecryptNoMatchingRecipientError))+          | fileName == "scrypt_work_factor_23.test" ->+            -- In our implementation, the maximum work factor value permitted+            -- is 64, but this test vector expects us to fail in the event that+            -- it's 23.+            --+            -- So, in order to appease this test vector, we set the maximum+            -- work factor for 'hIdentity' to 22 in the scrypt case. However,+            -- in our implementation, this results in a \"no match\" error and+            -- not a \"header failure\" error as is expected by the test+            -- vector. So we make an exception for this particular case.+            pure ()+        (ExpectPayloadFailure, Left (UnarmorOrDecryptDecryptError (DecryptDecryptPayloadError _))) -> pure ()+        (ExpectArmorFailure, Left (UnarmorOrDecryptUnarmorError _)) -> pure ()+        (_, Left err) -> fail $ "expected " <> show hExpect <> " but got the result: " <> show err+        (_, Right _) -> fail $ "expected " <> show hExpect <> " but got a success result"++mkTestVectorProperties :: IO [(String, PropertyT (ResourceT IO) ())]+mkTestVectorProperties = map mkTestVectorProperty <$> getTestVectorFileNames
+ test/Test/Gen.hs view
@@ -0,0 +1,13 @@+module Test.Gen+  ( genByteArray+  ) where++import Prelude++import Data.ByteArray ( ByteArray )+import qualified Data.ByteArray as BA+import Hedgehog ( Gen, Range )+import qualified Hedgehog.Gen as Gen++genByteArray :: ByteArray ba => Range Int -> Gen ba+genByteArray range = BA.convert <$> Gen.bytes range
+ test/Test/Golden.hs view
@@ -0,0 +1,27 @@+module Test.Golden+  ( goldenTestWithEncoderAndDecoder+  ) where++import Control.Monad.IO.Class ( liftIO )+import Data.ByteString ( ByteString )+import qualified Data.ByteString as BS+import GHC.Stack ( HasCallStack, withFrozenCallStack )+import Hedgehog ( Property, property, withTests, (===) )+import Prelude++-- | Construct a golden test given a binary encoder and decoder.+goldenTestWithEncoderAndDecoder ::+  (HasCallStack, Applicative f, Show (f a), Eq (f a)) =>+  -- | Encoder.+  (a -> ByteString) ->+  -- | Decoder.+  (ByteString -> f a) ->+  -- | Golden value.+  a ->+  -- | Golden file path.+  FilePath ->+  Property+goldenTestWithEncoderAndDecoder encode decode x path = withFrozenCallStack $ withTests 1 . property $ do+  bs <- liftIO (BS.readFile path)+  encode x === bs+  pure x === decode bs
+ test/golden/bech32/x25519-identity/golden.txt view
@@ -0,0 +1,1 @@+AGE-SECRET-KEY-1G9Q5ZS2PG9Q5ZS2PG9Q5ZS2PG9Q5ZS2PG9Q5ZS2PG9Q5ZS2PG9QSC006VW
+ test/golden/bech32/x25519-recipient/golden.txt view
@@ -0,0 +1,1 @@+age1g9q5zs2pg9q5zs2pg9q5zs2pg9q5zs2pg9q5zs2pg9q5zs2pg9qstququa
+ test/golden/binary/header-mac/golden.bin view
@@ -0,0 +1,1 @@+--- QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE
+ test/golden/binary/header/golden.bin view
@@ -0,0 +1,255 @@+age-encryption.org/v1+-> tag arg1 arg2 arg3 aaaaaaaaaaaaaaaaaa+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQQ+-> tag arg1 arg2 arg3 aaaaaaaaaaaaaaaaaa+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQQ+-> tag arg1 arg2 arg3 aaaaaaaaaaaaaaaaaa+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQQ+-> tag arg1 arg2 arg3 aaaaaaaaaaaaaaaaaa+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQQ+-> tag arg1 arg2 arg3 aaaaaaaaaaaaaaaaaa+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQQ+-> tag arg1 arg2 arg3 aaaaaaaaaaaaaaaaaa+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQQ+-> tag arg1 arg2 arg3 aaaaaaaaaaaaaaaaaa+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQQ+-> tag arg1 arg2 arg3 aaaaaaaaaaaaaaaaaa+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQQ+-> tag arg1 arg2 arg3 aaaaaaaaaaaaaaaaaa+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQQ+-> tag arg1 arg2 arg3 aaaaaaaaaaaaaaaaaa+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQQ+-> tag arg1 arg2 arg3 aaaaaaaaaaaaaaaaaa+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQQ+--- QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE
+ test/golden/binary/stanza/golden.bin view
@@ -0,0 +1,23 @@+-> tag arg1 arg2 arg3 aaaaaaaaaaaaaaaaaa+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQQ
+ test/golden/binary/work-factor/golden.bin view
@@ -0,0 +1,1 @@+13
+ test/golden/binary/x25519-identity/golden.bin view
@@ -0,0 +1,1 @@+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+ test/golden/binary/x25519-recipient/golden.bin view
@@ -0,0 +1,1 @@+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+ test/test-vectors/armor.test view
@@ -0,0 +1,13 @@+expect: success+payload: 013f54400c82da08037759ada907a8b864e97de81c088a182062c4b5622fd2ab+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y=+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_crlf.test view
@@ -0,0 +1,14 @@+expect: success+payload: 013f54400c82da08037759ada907a8b864e97de81c088a182062c4b5622fd2ab+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes+comment: CRLF is allowed as a end of line for armored files++-----BEGIN AGE ENCRYPTED FILE-----
+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY
+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW
+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz
+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS
+yPC8DpksHoMx+2Y=
+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_empty.test view
@@ -0,0 +1,6 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_empty_last_line.test view
@@ -0,0 +1,13 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW3bj4iHS+YS3WWUtZB5wJqKgEe8kpsp0iOnD2CNG4DVKBC0Z7SAcCFb8xdwV9CRavSEE7OU1c++-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_empty_line_begin.test view
@@ -0,0 +1,13 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----++YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y=+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_empty_line_end.test view
@@ -0,0 +1,13 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y=++-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_eol_between_padding.test view
@@ -0,0 +1,13 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW2ewwwqo+mNlxYv6gMOKyDNzgiw=+=+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_full_last_line.test view
@@ -0,0 +1,13 @@+expect: success+payload: 724a112a2cac139a4fca3ea0f799f2e5ccd1d0db46af654dee40567bff16ee33+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW3bj4iHS+YS3WWUtZB5wJqKgEe8kpsp0iOnD2CNG4DVKBC0Z7SAcCFb8xdwV9CRavSEE7OU1c+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_garbage_encoded.test view

binary file changed (absent → 997 bytes)

+ test/test-vectors/armor_garbage_leading.test view
@@ -0,0 +1,13 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++garbage+-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y=+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_garbage_trailing.test view
@@ -0,0 +1,13 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y=+-----END AGE ENCRYPTED FILE-----+garbage
+ test/test-vectors/armor_header_crlf.test view
@@ -0,0 +1,13 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0+armored: yes+comment: lines in the header end with CRLF instead of LF++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxDQotPiBYMjU1MTkgVEVpRjB5cHFyK2JwdmNx+WE55Q1ZKcEw3T3V3UGRWd1BMN0tRRWJGRE9DYw0KaGphYkdYd1NMUTljM1M2THcy+aStTMlR1MmZpd1FISHNsYkJONkI0MUZMRQ0KLS0tIDJLSUdiN3llMzJNV3RVdUVW+V2tPM01QNnFDREx6T3ZUOXdGMDZsZWxCU0kNCu7PYsfOkbQzJ05o1PL5E0y3TFv++976qUsjwvA6ZLB6DMftm+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_headers.test view
@@ -0,0 +1,15 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+Headers: are+Not: allowed++YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y=+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_invalid_character_header.test view
@@ -0,0 +1,12 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+YWdl*WVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y=+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_invalid_character_payload.test view
@@ -0,0 +1,12 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+*PC8DpksHoMx+2Y=+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_long_line.test view
@@ -0,0 +1,8 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FYTnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lWK0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkzZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpSyPC8DpksHoMx+2Y=+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_lowercase.test view
@@ -0,0 +1,12 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN age ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y=+-----END age ENCRYPTED FILE-----
+ test/test-vectors/armor_no_end_line.test view
@@ -0,0 +1,11 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y=
+ test/test-vectors/armor_no_eol.test view
@@ -0,0 +1,14 @@+expect: success+payload: 013f54400c82da08037759ada907a8b864e97de81c088a182062c4b5622fd2ab+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes+comment: there is no end of line at the end of the file++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y=+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_no_match.test view
@@ -0,0 +1,12 @@+expect: no match+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-143WN7DCXU4G8R5AXQSSYD9AEPYDNT3HXSLWSPK36CDU6E8M59SSSAGZ3KG+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBhanRxQXZERWtWTnIyQjd6+VU90cTJtQVFYRFNCbE5yVkF1TS9kS2I1c1Q0CkhVS3R6MFIyajVCbDJFUjdIaEFa+clVSaWtDRnBpSWpOYTBLakhjamJBR1UKLS0tIHJycFRsdktFS3JLM0VxaG9PUEpl+UDFLRThPMWQyYXJyUmV6Nzdtd2VrUmMK3d9y0G+8q1ffPQ0xJJatIYzX/W+AeLv4+gS3YeUcVXre9Xog=+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_no_padding.test view
@@ -0,0 +1,13 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes+comment: missing base64 padding++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_not_canonical.test view
@@ -0,0 +1,13 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes+comment: base64 is not canonical++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Z=+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_pgp_checksum.test view
@@ -0,0 +1,14 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----++YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y=+=J2ub+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_short_line.test view
@@ -0,0 +1,12 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRp+b24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FYTnlDVkpwTDdPdXdQ+ZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lWK0h1MHIrRThSNzdE+ZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkzZjFzcUhqbHUvejFM+Q1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpSyPC8DpksHoMx+2Y=+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_whitespace_begin.test view
@@ -0,0 +1,12 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++----- BEGIN AGE ENCRYPTED FILE -----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y=+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_whitespace_end.test view
@@ -0,0 +1,12 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y=+----- END AGE ENCRYPTED FILE -----
+ test/test-vectors/armor_whitespace_eol.test view
@@ -0,0 +1,12 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS +yPC8DpksHoMx+2Y=+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_whitespace_last_line.test view
@@ -0,0 +1,12 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y= +-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_whitespace_line_start.test view
@@ -0,0 +1,12 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y=+-----END AGE ENCRYPTED FILE-----
+ test/test-vectors/armor_whitespace_outside.test view
@@ -0,0 +1,18 @@+expect: success+payload: 013f54400c82da08037759ada907a8b864e97de81c088a182062c4b5622fd2ab+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes+comment: whitespace is allowed before and after armored files+++
   	+-----BEGIN AGE ENCRYPTED FILE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y=+-----END AGE ENCRYPTED FILE-----++
   	
+ test/test-vectors/armor_wrong_type.test view
@@ -0,0 +1,12 @@+expect: armor failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+armored: yes++-----BEGIN AGE ENCRYPTED MESSAGE-----+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURWlGMHlwcXIrYnB2Y3FY+TnlDVkpwTDdPdXdQZFZ3UEw3S1FFYkZET0NjCkVtRUNBRWNLTituL1ZzOVNiV2lW+K0h1MHIrRThSNzdEZFdZeWQ4M253N1UKLS0tIFZuKzU0anFpaVVDRStXWmNFVlkz+ZjFzcUhqbHUvejFMQ1EvVDdYbTdxSTAK7s9ix86RtDMnTmjU8vkTTLdMW/73vqpS+yPC8DpksHoMx+2Y=+-----END AGE ENCRYPTED MESSAGE-----
+ test/test-vectors/empty.test view
@@ -0,0 +1,3 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+
+ test/test-vectors/header_crlf.test view
@@ -0,0 +1,10 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0+comment: lines in the header end with CRLF instead of LF++age-encryption.org/v1
+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc
+hjabGXwSLQ9c3S6Lw2i+S2Tu2fiwQHHslbBN6B41FLE
+--- 2KIGb7ye32MWtUuEVWkO3MP6qCDLzOvT9wF06lelBSI
+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/hmac_bad.test view
@@ -0,0 +1,9 @@+expect: HMAC failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+hjabGXwSLQ9c3S6Lw2i+S2Tu2fiwQHHslbBN6B41FLE+--- 8McE3ix9R34E/vLrQv3yepsHjo/LXhfs22Ab3UyInmg+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/hmac_extra_space.test view
@@ -0,0 +1,9 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+hjabGXwSLQ9c3S6Lw2i+S2Tu2fiwQHHslbBN6B41FLE+---  WyJp9F/9FOZh7gJdheq2WIJcwHgYc8NIVh3ddwhrcNg+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/hmac_garbage.test view
@@ -0,0 +1,9 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+hjabGXwSLQ9c3S6Lw2i+S2Tu2fiwQHHslbBN6B41FLE+--- WyJp9F/9FOZh7gJdheq2WIJcwHgYc8NIVh3ddwhrcNgAAA+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/hmac_missing.test view
@@ -0,0 +1,9 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+hjabGXwSLQ9c3S6Lw2i+S2Tu2fiwQHHslbBN6B41FLE+--- +îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/hmac_no_space.test view
@@ -0,0 +1,9 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+hjabGXwSLQ9c3S6Lw2i+S2Tu2fiwQHHslbBN6B41FLE+---WyJp9F/9FOZh7gJdheq2WIJcwHgYc8NIVh3ddwhrcNg+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/hmac_not_canonical.test view
@@ -0,0 +1,10 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0+comment: the base64 encoding of the HMAC is not canonical++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+hjabGXwSLQ9c3S6Lw2i+S2Tu2fiwQHHslbBN6B41FLE+--- WyJp9F/9FOZh7gJdheq2WIJcwHgYc8NIVh3ddwhrcNh+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/hmac_trailing_space.test view
@@ -0,0 +1,9 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+hjabGXwSLQ9c3S6Lw2i+S2Tu2fiwQHHslbBN6B41FLE+--- WyJp9F/9FOZh7gJdheq2WIJcwHgYc8NIVh3ddwhrcNg +îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/hmac_truncated.test view
@@ -0,0 +1,9 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+hjabGXwSLQ9c3S6Lw2i+S2Tu2fiwQHHslbBN6B41FLE+--- WyJp+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/scrypt.test view

binary file changed (absent → 340 bytes)

+ test/test-vectors/scrypt_and_x25519.test view
@@ -0,0 +1,13 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-143WN7DCXU4G8R5AXQSSYD9AEPYDNT3HXSLWSPK36CDU6E8M59SSSAGZ3KG+passphrase: password+comment: scrypt stanzas must be alone in the header++age-encryption.org/v1+-> X25519 ajtqAvDEkVNr2B7zUOtq2mAQXDSBlNrVAuM/dKb5sT4+U+hKlJ4isweJ9PKG7pgscmG3cPASLgTw7SOBpbZ8x2U+-> scrypt 3d9y0G+8q1ffPQ0xJJatIQ 10+foZolxuhRSL7IG7oaR+456IzkHtvue7j4mUjh3DB6EI+--- yp4Z0lV1LEdkm1+uDCuPUV+9hIXbPKrBXKQ/f5Y03As+T^kôƉ>)ìÍ,r±ÌFlž'cÏô’‚ž›¸Vµ
+ test/test-vectors/scrypt_bad_tag.test view

binary file changed (absent → 358 bytes)

+ test/test-vectors/scrypt_double.test view
@@ -0,0 +1,13 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+passphrase: password+passphrase: hunter2+comment: scrypt stanzas must be alone in the header++age-encryption.org/v1+-> scrypt rF0/NwblUHHTpgQgRpe5CQ 10+gUjEymFKMVXQEKdMMHL24oYexjE3TIC0O0zGSqJ2aUY+-> scrypt GzXG5ofdANo6w3msn3QsIQ 10+OveITuwxakv7k2oLnioNYF4Bhgz9KZ36pb098wDoAv8+--- a5d+4Ay1evJhoDskIzuTZV9bBgKk4573VZNfuoWJDPE+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/scrypt_extra_argument.test view

binary file changed (absent → 335 bytes)

+ test/test-vectors/scrypt_long_file_key.test view

binary file changed (absent → 377 bytes)

+ test/test-vectors/scrypt_no_match.test view

binary file changed (absent → 264 bytes)

+ test/test-vectors/scrypt_not_canonical_body.test view

binary file changed (absent → 332 bytes)

+ test/test-vectors/scrypt_not_canonical_salt.test view

binary file changed (absent → 273 bytes)

+ test/test-vectors/scrypt_salt_long.test view

binary file changed (absent → 278 bytes)

+ test/test-vectors/scrypt_salt_missing.test view
@@ -0,0 +1,9 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+passphrase: password++age-encryption.org/v1+-> scrypt 10+W0mMthyhNJOV3debCwkQcUlNx/i6Ss/A07aQCrG5Gcw+--- 1QsPcEbBSylfP4apakJqtDBJMrpd81rPuSLTCvdZx6E+¬]?7åPqÓ¦ F—¹	•Â÷õÛ®è
zŒ(rŠóÎ|
+ test/test-vectors/scrypt_salt_short.test view

binary file changed (absent → 267 bytes)

+ test/test-vectors/scrypt_uppercase.test view

binary file changed (absent → 267 bytes)

+ test/test-vectors/scrypt_work_factor_23.test view
@@ -0,0 +1,10 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+passphrase: password+comment: work factor is very high, would take a long time to compute++age-encryption.org/v1+-> scrypt rF0/NwblUHHTpgQgRpe5CQ 23+qW9eVsT0NVb/Vswtw8kPIxUnaYmm9Px1dYmq2+4+qZA+--- 38TpQMxQRRNMfmYYpBX6DDrPx4/QY5UmJnhPyVoX/cw+¬]?7åPqÓ¦ F—¹	•Â÷õÛ®è
zŒ(rŠóÎ|
+ test/test-vectors/scrypt_work_factor_hex.test view

binary file changed (absent → 274 bytes)

+ test/test-vectors/scrypt_work_factor_leading_garbage.test view

binary file changed (absent → 277 bytes)

+ test/test-vectors/scrypt_work_factor_leading_plus.test view

binary file changed (absent → 274 bytes)

+ test/test-vectors/scrypt_work_factor_leading_zero_decimal.test view

binary file changed (absent → 274 bytes)

+ test/test-vectors/scrypt_work_factor_leading_zero_octal.test view

binary file changed (absent → 274 bytes)

+ test/test-vectors/scrypt_work_factor_missing.test view

binary file changed (absent → 270 bytes)

+ test/test-vectors/scrypt_work_factor_negative.test view

binary file changed (absent → 274 bytes)

+ test/test-vectors/scrypt_work_factor_overflow.test view

binary file changed (absent → 290 bytes)

+ test/test-vectors/scrypt_work_factor_trailing_garbage.test view

binary file changed (absent → 277 bytes)

+ test/test-vectors/scrypt_work_factor_wrong.test view

binary file changed (absent → 267 bytes)

+ test/test-vectors/scrypt_work_factor_zero.test view

binary file changed (absent → 272 bytes)

+ test/test-vectors/stanza_bad_start.test view
@@ -0,0 +1,11 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+-- stanza++--- lpxzkyQGe/sA7F1yh4c6KVZV7//jANm5lYefTToioXs+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/stanza_base64_padding.test view
@@ -0,0 +1,12 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+-> stanza+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUE=+--- OtG7IuNHaf2SHZuowmxg/fhbhtz0/DI5g5OGd7WH7S0+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/stanza_empty_argument.test view
@@ -0,0 +1,11 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+-> stanza  argument++--- bosBxVRBzKF9emyxQ9BERq7+D5JKU+lvbEsL8UHJ/SA+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/stanza_empty_body.test view
@@ -0,0 +1,12 @@+expect: success+payload: 013f54400c82da08037759ada907a8b864e97de81c088a182062c4b5622fd2ab+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+-> empty++--- 697zSC9pa/ZLNIaXGtuwcUobmxv+Dpx48Hv0papk5c0+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/stanza_empty_last_line.test view
@@ -0,0 +1,14 @@+expect: success+payload: 013f54400c82da08037759ada907a8b864e97de81c088a182062c4b5622fd2ab+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+-> stanza+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB++--- cb4SqtunSJzXKDGjqeYxuva9Be80QXEDKDn2aKBaCsw+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/stanza_invalid_character.test view
@@ -0,0 +1,11 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+-> stanza è++--- sTIB/0Fc74rhpjC4RAxoR3E01eVTTnWruaD+c5QWjKI+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/stanza_long_line.test view
@@ -0,0 +1,13 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+comment: a body line is longer than 64 columns++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+-> stanza+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA++--- tnRUR2vmmU92czsjnioF5ujgXUetUhzUoQPPGT9wmug+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/stanza_missing_body.test view
@@ -0,0 +1,11 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+comment: every stanza must end with a short body line, even if empty++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+-> empty+--- CDgFIIJ1wE4CpW6zG+LVZ6/G/RCNTH6ZUVGp2NbeIkU+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/stanza_missing_final_line.test view
@@ -0,0 +1,12 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+comment: every stanza must end with a short body line++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+-> stanza+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+--- GRjUy1ShNhFoV3cQikdtUZqDeDEZSrbtNXUgDtDbwC8+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/stanza_multiple_short_lines.test view
@@ -0,0 +1,13 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+comment: a short body line ends the stanza++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+-> stanza+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+--- ct87HSIMoTC4nUsQva+8AeKc2bK2q8b9sPjRhjuf1us+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/stanza_no_arguments.test view
@@ -0,0 +1,11 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+->++--- B0qjnUjVajTa8I4Uia49g1c4DMQQN6u9m9QOSS1HLks+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/stanza_not_canonical.test view
@@ -0,0 +1,12 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+-> stanza+QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB+QUF+--- nQM2VCzmNLPrUurNWN+SW9wVp/9uTMQ/6CTUM7l8c84+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/stanza_spurious_cr.test view
@@ -0,0 +1,11 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+-> stanza+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+--- MZaFAh8ldzU0F88NJjLx5yd7fnd57XS5COowmgvQtXQ+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/stanza_valid_characters.test view
@@ -0,0 +1,14 @@+expect: success+payload: 013f54400c82da08037759ada907a8b864e97de81c088a182062c4b5622fd2ab+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> !"#$%&' ()*+,-./ 01234567 89:;<=>? @ABCDEFG HIJKLMNO++-> PQRSTUVW XYZ[\]^_ `abcdefg hijklmno pqrstuvw xyz{|}~++-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+--- x538z9xJq9XEK1aTTTv80aWDVvVdROvaXn2tpqXPC8g+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/stream_257_chunks.test view

binary file changed (absent → 22123 bytes)

+ test/test-vectors/stream_257_chunks_full.test view

binary file changed (absent → 22187 bytes)

+ test/test-vectors/stream_258_chunks.test view

binary file changed (absent → 22213 bytes)

+ test/test-vectors/stream_bad_tag.test view
@@ -0,0 +1,10 @@+expect: payload failure+payload: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+--- Vn+54jqiiUCE+WZcEVY3f1sqHjlu/z1LCQ/T7Xm7qI0+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûF
+ test/test-vectors/stream_bad_tag_second_chunk.test view

binary file changed (absent → 587 bytes)

+ test/test-vectors/stream_bad_tag_second_chunk_full.test view

binary file changed (absent → 654 bytes)

+ test/test-vectors/stream_empty_payload.test view
@@ -0,0 +1,10 @@+expect: success+payload: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+--- Vn+54jqiiUCE+WZcEVY3f1sqHjlu/z1LCQ/T7Xm7qI0+îÏbÇΑ´3'NhÔòùL­.OÏ>RŠA0Þ«ïC6åU
+ test/test-vectors/stream_last_chunk_empty.test view

binary file changed (absent → 662 bytes)

+ test/test-vectors/stream_last_chunk_full.test view

binary file changed (absent → 557 bytes)

+ test/test-vectors/stream_last_chunk_full_second.test view

binary file changed (absent → 647 bytes)

+ test/test-vectors/stream_missing_tag.test view
@@ -0,0 +1,10 @@+expect: payload failure+payload: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+--- Vn+54jqiiUCE+WZcEVY3f1sqHjlu/z1LCQ/T7Xm7qI0+îÏbÇΑ´3'NhÔòùL·L[
+ test/test-vectors/stream_no_chunks.test view
@@ -0,0 +1,10 @@+expect: payload failure+payload: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+--- Vn+54jqiiUCE+WZcEVY3f1sqHjlu/z1LCQ/T7Xm7qI0+îÏbÇΑ´3'NhÔòùL
+ test/test-vectors/stream_no_final.test view

binary file changed (absent → 428 bytes)

+ test/test-vectors/stream_no_final_full.test view

binary file changed (absent → 563 bytes)

+ test/test-vectors/stream_no_final_two_chunks.test view

binary file changed (absent → 587 bytes)

+ test/test-vectors/stream_no_final_two_chunks_full.test view

binary file changed (absent → 656 bytes)

+ test/test-vectors/stream_no_nonce.test view
@@ -0,0 +1,8 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+--- Vn+54jqiiUCE+WZcEVY3f1sqHjlu/z1LCQ/T7Xm7qI0
+ test/test-vectors/stream_short_chunk.test view
@@ -0,0 +1,10 @@+expect: payload failure+payload: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+--- Vn+54jqiiUCE+WZcEVY3f1sqHjlu/z1LCQ/T7Xm7qI0+îÏbÇΑ´3'NhÔòùL[æè.½Ó#Èw
+ test/test-vectors/stream_short_nonce.test view
@@ -0,0 +1,9 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+--- Vn+54jqiiUCE+WZcEVY3f1sqHjlu/z1LCQ/T7Xm7qI0+îÏbÇΑ´3'NhÔ
+ test/test-vectors/stream_short_second_chunk.test view

binary file changed (absent → 581 bytes)

+ test/test-vectors/stream_three_chunks.test view

binary file changed (absent → 737 bytes)

+ test/test-vectors/stream_trailing_garbage_long.test view

binary file changed (absent → 593 bytes)

+ test/test-vectors/stream_trailing_garbage_short.test view

binary file changed (absent → 582 bytes)

+ test/test-vectors/stream_two_chunks.test view

binary file changed (absent → 647 bytes)

+ test/test-vectors/stream_two_final_chunks.test view

binary file changed (absent → 590 bytes)

+ test/test-vectors/stream_two_final_chunks_full.test view

binary file changed (absent → 655 bytes)

+ test/test-vectors/stream_two_final_chunks_second.test view

binary file changed (absent → 678 bytes)

+ test/test-vectors/stream_two_final_chunks_short.test view

binary file changed (absent → 445 bytes)

+ test/test-vectors/version_unsupported.test view
@@ -0,0 +1,9 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1234+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+--- 38AL8Mr4VwmS6CNbM4bc7u3WwGBDqsMTRHOuYJ9ckqs+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/x25519.test view
@@ -0,0 +1,10 @@+expect: success+payload: 013f54400c82da08037759ada907a8b864e97de81c088a182062c4b5622fd2ab+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+--- Vn+54jqiiUCE+WZcEVY3f1sqHjlu/z1LCQ/T7Xm7qI0+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/x25519_bad_tag.test view
@@ -0,0 +1,10 @@+expect: no match+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+comment: the ChaCha20Poly1305 authentication tag on the body of the X25519 stanza is wrong++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw0o+--- tG0k9bg4iIuBdMWb13n7FFYDzoBbtsLppNLhbh22aKg+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/x25519_extra_argument.test view
@@ -0,0 +1,10 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+comment: the base64 encoding of the share is not canonical++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc 1234+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+--- hQQySEUXL8pOuIOuw0qXzi66RphDJP9IKMNEChNJIPk+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/x25519_grease.test view
@@ -0,0 +1,14 @@+expect: success+payload: 013f54400c82da08037759ada907a8b864e97de81c088a182062c4b5622fd2ab+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> grease++-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+-> grease++--- 7NLrfbRUZt6qK0pdtARUf59dHwo12ReldjJKjMlbE3I+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/x25519_identity.test view
@@ -0,0 +1,10 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0+comment: the X25519 share is a low-order point, so the shared secret is the disallowed all-zero value++age-encryption.org/v1+-> X25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+W3E/OCRme9TiTY97JoK31Z71arNur77WIIdB90XnN3M+--- Pne3IPMDvBj7wRbPMcNViffpVZAx814tgMxp8AwyMhs+¬]?7åPqÓ¦ F—¹	•Â÷õÛ®è
zŒ(rŠóÎ|
+ test/test-vectors/x25519_long_file_key.test view
@@ -0,0 +1,10 @@+expect: header failure+file key: 41204c4f4e4745522059454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0+comment: the file key must be checked to be 16 bytes before decrypting it++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+nlObGn0CSA4pxiaG3W6nLlaFFuHmqW+bFC6sJmbsJ9yFesgSok1K0AI+--- C49Jo3+j4I6jWB2tldSs1jVAXbv0mOTAnwdT+5vOiBg+îÏbÇΑ´3'NhÔòùLc÷(­çñ
ÐtÿǏP²)€x1
+ test/test-vectors/x25519_long_share.test view
@@ -0,0 +1,10 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0+comment: a trailing zero is missing from the X25519 share++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCcA+hjabGXwSLQ9c3S6Lw2i+S2Tu2fiwQHHslbBN6B41FLE+--- QbEwdWirchS37UUOPh7uVddRiOaWjFwRUpaQ4Q+Z1RE+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/x25519_low_order.test view
@@ -0,0 +1,10 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0+comment: the X25519 share is a low-order point, so the shared secretis the disallowed all-zero value++age-encryption.org/v1+-> X25519 X5yVvKNQjCSx0LFVnIPvWwREXMRYHI6G2CJO3dCfEdc+3E0NpFans/m0WLWF7+54ZBdNj3iqQqpraGDFiaRkvBA+--- sXw327YMT1/ULXe+ZyRMbMY0Z2jnWHGgI9j1we6yQ8A+¬]?7åPqÓ¦ F—¹	•Â÷õÛ®è
zŒ(rŠóÎ|
+ test/test-vectors/x25519_lowercase.test view
@@ -0,0 +1,10 @@+expect: no match+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+comment: the first argument in the X25519 stanza is lowercase++age-encryption.org/v1+-> x25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+--- SwXKO3dXLh9l5QiSgMWgPhCkwstT8oB4jLDv7aBgC+c+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/x25519_multiple_recipients.test view
@@ -0,0 +1,12 @@+expect: success+payload: 013f54400c82da08037759ada907a8b864e97de81c088a182062c4b5622fd2ab+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6++age-encryption.org/v1+-> X25519 ajtqAvDEkVNr2B7zUOtq2mAQXDSBlNrVAuM/dKb5sT4+0evrK/HQXVsQ4YaDe+659l5OQzvAzD2ytLGHQLQiqxg+-> X25519 0qC7u6AbLxuwnM8tPFOWVtWZn/ZZe7z7gcsP5kgA0FI+T/PZg76MmVt2IaLntrxppzDnzeFDYHsHFcnTnhbRLQ8+--- 7W07ef2PhsTAl74pn+9vSj/Xzukwa6SuTqMc16cdBk0+ð¸¾5TB9™ ­€„–Ko•Ãm³^OYØøž<òo-¥B
+ test/test-vectors/x25519_no_match.test view
@@ -0,0 +1,9 @@+expect: no match+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-143WN7DCXU4G8R5AXQSSYD9AEPYDNT3HXSLWSPK36CDU6E8M59SSSAGZ3KG++age-encryption.org/v1+-> X25519 ajtqAvDEkVNr2B7zUOtq2mAQXDSBlNrVAuM/dKb5sT4+HUKtz0R2j5Bl2ER7HhAZrURikCFpiIjNa0KjHcjbAGU+--- rrpTlvKEKrK3EqhoOPJeP1KE8O1d2arrRez77mwekRc+ÝßrÐo¼«Wß=
1$–­!Œ×ýo€x»ø-ØyG^·½^ˆ
+ test/test-vectors/x25519_not_canonical_body.test view
@@ -0,0 +1,10 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+comment: the base64 encoding of the share is not canonical++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7V+--- eSjjCjQyp30yHDPwCztKS+1txs+aoCa5ERz8jeEp+9A+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/x25519_not_canonical_share.test view
@@ -0,0 +1,10 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6+comment: the base64 encoding of the share is not canonical++age-encryption.org/v1+-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCd+EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U+--- AO6haEGU6BGJ8Tzeqnr2fSLEo31JrWodGtZuCZmijI8+îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf
+ test/test-vectors/x25519_short_share.test view
@@ -0,0 +1,10 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+identity: AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0+comment: a trailing zero is missing from the X25519 share++age-encryption.org/v1+-> X25519 l7o4oTX9X5E3/KODa/7CQ0CrA9fKMWsm9IJjYzSlJg+yUGP5aPob6YJ+vzRfBtDT9D1K/wmyheZE/Xl/mDSKA4+--- Zn1/VRtHpD93HtIXSv1S++POXeKcQF7w1+hpXhMiAbk+¬]?7åPqÓ¦ F—¹	•Â÷õÛ®è
zŒ(rŠóÎ|