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 +5/−0
- LICENSE +21/−0
- README.md +8/−0
- Setup.hs +3/−0
- age.cabal +146/−0
- src/Crypto/Age.hs +100/−0
- src/Crypto/Age/Armor.hs +230/−0
- src/Crypto/Age/Buffered.hs +77/−0
- src/Crypto/Age/Conduit.hs +706/−0
- src/Crypto/Age/Header.hs +265/−0
- src/Crypto/Age/Identity.hs +126/−0
- src/Crypto/Age/Identity/Stanza.hs +310/−0
- src/Crypto/Age/Key.hs +163/−0
- src/Crypto/Age/Payload/Ciphertext.hs +138/−0
- src/Crypto/Age/Payload/Counter.hs +100/−0
- src/Crypto/Age/Payload/Plaintext.hs +107/−0
- src/Crypto/Age/Recipient.hs +120/−0
- src/Crypto/Age/Recipient/Stanza.hs +376/−0
- src/Crypto/Age/Scrypt.hs +116/−0
- src/Data/Attoparsec/ByteString/Base64.hs +35/−0
- src/Data/Attoparsec/ByteString/Extra.hs +84/−0
- src/Data/Binary/Put/Integer.hs +27/−0
- src/Data/ByteString/Base64/Extra.hs +40/−0
- src/Data/ByteString/Extra.hs +16/−0
- src/Data/Conduit/Base64.hs +87/−0
- src/Data/List/Compat.hs +55/−0
- test/Main.hs +25/−0
- test/Test/Crypto/Age/Armor.hs +87/−0
- test/Test/Crypto/Age/Conduit.hs +156/−0
- test/Test/Crypto/Age/Header.hs +119/−0
- test/Test/Crypto/Age/Header/Gen.hs +51/−0
- test/Test/Crypto/Age/Identity.hs +82/−0
- test/Test/Crypto/Age/Identity/Gen.hs +38/−0
- test/Test/Crypto/Age/Key.hs +118/−0
- test/Test/Crypto/Age/Key/Gen.hs +40/−0
- test/Test/Crypto/Age/Key/Render.hs +17/−0
- test/Test/Crypto/Age/Payload/Counter/Gen.hs +17/−0
- test/Test/Crypto/Age/Payload/Plaintext/Gen.hs +47/−0
- test/Test/Crypto/Age/Recipient.hs +83/−0
- test/Test/Crypto/Age/Recipient/Gen.hs +45/−0
- test/Test/Crypto/Age/Scrypt.hs +135/−0
- test/Test/Crypto/Age/Scrypt/Gen.hs +45/−0
- test/Test/Crypto/Age/TestVector.hs +25/−0
- test/Test/Crypto/Age/TestVector/Header.hs +246/−0
- test/Test/Crypto/Age/TestVector/Property.hs +116/−0
- test/Test/Gen.hs +13/−0
- test/Test/Golden.hs +27/−0
- test/golden/bech32/x25519-identity/golden.txt +1/−0
- test/golden/bech32/x25519-recipient/golden.txt +1/−0
- test/golden/binary/header-mac/golden.bin +1/−0
- test/golden/binary/header/golden.bin +255/−0
- test/golden/binary/stanza/golden.bin +23/−0
- test/golden/binary/work-factor/golden.bin +1/−0
- test/golden/binary/x25519-identity/golden.bin +1/−0
- test/golden/binary/x25519-recipient/golden.bin +1/−0
- test/test-vectors/armor.test +13/−0
- test/test-vectors/armor_crlf.test +14/−0
- test/test-vectors/armor_empty.test +6/−0
- test/test-vectors/armor_empty_last_line.test +13/−0
- test/test-vectors/armor_empty_line_begin.test +13/−0
- test/test-vectors/armor_empty_line_end.test +13/−0
- test/test-vectors/armor_eol_between_padding.test +13/−0
- test/test-vectors/armor_full_last_line.test +13/−0
- test/test-vectors/armor_garbage_encoded.test binary
- test/test-vectors/armor_garbage_leading.test +13/−0
- test/test-vectors/armor_garbage_trailing.test +13/−0
- test/test-vectors/armor_header_crlf.test +13/−0
- test/test-vectors/armor_headers.test +15/−0
- test/test-vectors/armor_invalid_character_header.test +12/−0
- test/test-vectors/armor_invalid_character_payload.test +12/−0
- test/test-vectors/armor_long_line.test +8/−0
- test/test-vectors/armor_lowercase.test +12/−0
- test/test-vectors/armor_no_end_line.test +11/−0
- test/test-vectors/armor_no_eol.test +14/−0
- test/test-vectors/armor_no_match.test +12/−0
- test/test-vectors/armor_no_padding.test +13/−0
- test/test-vectors/armor_not_canonical.test +13/−0
- test/test-vectors/armor_pgp_checksum.test +14/−0
- test/test-vectors/armor_short_line.test +12/−0
- test/test-vectors/armor_whitespace_begin.test +12/−0
- test/test-vectors/armor_whitespace_end.test +12/−0
- test/test-vectors/armor_whitespace_eol.test +12/−0
- test/test-vectors/armor_whitespace_last_line.test +12/−0
- test/test-vectors/armor_whitespace_line_start.test +12/−0
- test/test-vectors/armor_whitespace_outside.test +18/−0
- test/test-vectors/armor_wrong_type.test +12/−0
- test/test-vectors/empty.test +3/−0
- test/test-vectors/header_crlf.test +10/−0
- test/test-vectors/hmac_bad.test +9/−0
- test/test-vectors/hmac_extra_space.test +9/−0
- test/test-vectors/hmac_garbage.test +9/−0
- test/test-vectors/hmac_missing.test +9/−0
- test/test-vectors/hmac_no_space.test +9/−0
- test/test-vectors/hmac_not_canonical.test +10/−0
- test/test-vectors/hmac_trailing_space.test +9/−0
- test/test-vectors/hmac_truncated.test +9/−0
- test/test-vectors/scrypt.test binary
- test/test-vectors/scrypt_and_x25519.test +13/−0
- test/test-vectors/scrypt_bad_tag.test binary
- test/test-vectors/scrypt_double.test +13/−0
- test/test-vectors/scrypt_extra_argument.test binary
- test/test-vectors/scrypt_long_file_key.test binary
- test/test-vectors/scrypt_no_match.test binary
- test/test-vectors/scrypt_not_canonical_body.test binary
- test/test-vectors/scrypt_not_canonical_salt.test binary
- test/test-vectors/scrypt_salt_long.test binary
- test/test-vectors/scrypt_salt_missing.test +9/−0
- test/test-vectors/scrypt_salt_short.test binary
- test/test-vectors/scrypt_uppercase.test binary
- test/test-vectors/scrypt_work_factor_23.test +10/−0
- test/test-vectors/scrypt_work_factor_hex.test binary
- test/test-vectors/scrypt_work_factor_leading_garbage.test binary
- test/test-vectors/scrypt_work_factor_leading_plus.test binary
- test/test-vectors/scrypt_work_factor_leading_zero_decimal.test binary
- test/test-vectors/scrypt_work_factor_leading_zero_octal.test binary
- test/test-vectors/scrypt_work_factor_missing.test binary
- test/test-vectors/scrypt_work_factor_negative.test binary
- test/test-vectors/scrypt_work_factor_overflow.test binary
- test/test-vectors/scrypt_work_factor_trailing_garbage.test binary
- test/test-vectors/scrypt_work_factor_wrong.test binary
- test/test-vectors/scrypt_work_factor_zero.test binary
- test/test-vectors/stanza_bad_start.test +11/−0
- test/test-vectors/stanza_base64_padding.test +12/−0
- test/test-vectors/stanza_empty_argument.test +11/−0
- test/test-vectors/stanza_empty_body.test +12/−0
- test/test-vectors/stanza_empty_last_line.test +14/−0
- test/test-vectors/stanza_invalid_character.test +11/−0
- test/test-vectors/stanza_long_line.test +13/−0
- test/test-vectors/stanza_missing_body.test +11/−0
- test/test-vectors/stanza_missing_final_line.test +12/−0
- test/test-vectors/stanza_multiple_short_lines.test +13/−0
- test/test-vectors/stanza_no_arguments.test +11/−0
- test/test-vectors/stanza_not_canonical.test +12/−0
- test/test-vectors/stanza_spurious_cr.test +11/−0
- test/test-vectors/stanza_valid_characters.test +14/−0
- test/test-vectors/stream_257_chunks.test binary
- test/test-vectors/stream_257_chunks_full.test binary
- test/test-vectors/stream_258_chunks.test binary
- test/test-vectors/stream_bad_tag.test +10/−0
- test/test-vectors/stream_bad_tag_second_chunk.test binary
- test/test-vectors/stream_bad_tag_second_chunk_full.test binary
- test/test-vectors/stream_empty_payload.test +10/−0
- test/test-vectors/stream_last_chunk_empty.test binary
- test/test-vectors/stream_last_chunk_full.test binary
- test/test-vectors/stream_last_chunk_full_second.test binary
- test/test-vectors/stream_missing_tag.test +10/−0
- test/test-vectors/stream_no_chunks.test +10/−0
- test/test-vectors/stream_no_final.test binary
- test/test-vectors/stream_no_final_full.test binary
- test/test-vectors/stream_no_final_two_chunks.test binary
- test/test-vectors/stream_no_final_two_chunks_full.test binary
- test/test-vectors/stream_no_nonce.test +8/−0
- test/test-vectors/stream_short_chunk.test +10/−0
- test/test-vectors/stream_short_nonce.test +9/−0
- test/test-vectors/stream_short_second_chunk.test binary
- test/test-vectors/stream_three_chunks.test binary
- test/test-vectors/stream_trailing_garbage_long.test binary
- test/test-vectors/stream_trailing_garbage_short.test binary
- test/test-vectors/stream_two_chunks.test binary
- test/test-vectors/stream_two_final_chunks.test binary
- test/test-vectors/stream_two_final_chunks_full.test binary
- test/test-vectors/stream_two_final_chunks_second.test binary
- test/test-vectors/stream_two_final_chunks_short.test binary
- test/test-vectors/version_unsupported.test +9/−0
- test/test-vectors/x25519.test +10/−0
- test/test-vectors/x25519_bad_tag.test +10/−0
- test/test-vectors/x25519_extra_argument.test +10/−0
- test/test-vectors/x25519_grease.test +14/−0
- test/test-vectors/x25519_identity.test +10/−0
- test/test-vectors/x25519_long_file_key.test +10/−0
- test/test-vectors/x25519_long_share.test +10/−0
- test/test-vectors/x25519_low_order.test +10/−0
- test/test-vectors/x25519_lowercase.test +10/−0
- test/test-vectors/x25519_multiple_recipients.test +12/−0
- test/test-vectors/x25519_no_match.test +9/−0
- test/test-vectors/x25519_not_canonical_body.test +10/−0
- test/test-vectors/x25519_not_canonical_share.test +10/−0
- test/test-vectors/x25519_short_share.test +10/−0
@@ -0,0 +1,5 @@+# ChangeLog for age++## 0.0.1.0 -- 2025-09-15++- Initial pre-release.
@@ -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.
@@ -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.
@@ -0,0 +1,3 @@+import Distribution.Simple++main = defaultMain
@@ -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
@@ -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+ )
@@ -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
@@ -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)
@@ -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)
@@ -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+ }
@@ -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)
@@ -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
@@ -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)
@@ -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)
@@ -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
@@ -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)
@@ -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)
@@ -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
@@ -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)
@@ -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
@@ -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)
@@ -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 #-}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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+ ]
@@ -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"
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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+ ]
@@ -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
@@ -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
@@ -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)
@@ -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
@@ -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)+ ]
@@ -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
@@ -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+ ]
@@ -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
@@ -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)
@@ -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)
@@ -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"
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,1 @@+AGE-SECRET-KEY-1G9Q5ZS2PG9Q5ZS2PG9Q5ZS2PG9Q5ZS2PG9Q5ZS2PG9Q5ZS2PG9QSC006VW
@@ -0,0 +1,1 @@+age1g9q5zs2pg9q5zs2pg9q5zs2pg9q5zs2pg9q5zs2pg9q5zs2pg9qstququa
@@ -0,0 +1,1 @@+--- QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE
@@ -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
@@ -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
@@ -0,0 +1,1 @@+13
@@ -0,0 +1,1 @@+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
@@ -0,0 +1,1 @@+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
@@ -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-----
@@ -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-----
@@ -0,0 +1,6 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+armored: yes++-----BEGIN AGE ENCRYPTED FILE-----+-----END AGE ENCRYPTED FILE-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
binary file changed (absent → 997 bytes)
@@ -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-----
@@ -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
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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=
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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 -----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----++
@@ -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-----
@@ -0,0 +1,3 @@+expect: header failure+file key: 59454c4c4f57205355424d4152494e45+
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
binary file changed (absent → 340 bytes)
@@ -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µ
binary file changed (absent → 358 bytes)
@@ -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
binary file changed (absent → 335 bytes)
binary file changed (absent → 377 bytes)
binary file changed (absent → 264 bytes)
binary file changed (absent → 332 bytes)
binary file changed (absent → 273 bytes)
binary file changed (absent → 278 bytes)
@@ -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óÎ|
binary file changed (absent → 267 bytes)
binary file changed (absent → 267 bytes)
@@ -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óÎ|
binary file changed (absent → 274 bytes)
binary file changed (absent → 277 bytes)
binary file changed (absent → 274 bytes)
binary file changed (absent → 274 bytes)
binary file changed (absent → 274 bytes)
binary file changed (absent → 270 bytes)
binary file changed (absent → 274 bytes)
binary file changed (absent → 290 bytes)
binary file changed (absent → 277 bytes)
binary file changed (absent → 267 bytes)
binary file changed (absent → 272 bytes)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
binary file changed (absent → 22123 bytes)
binary file changed (absent → 22187 bytes)
binary file changed (absent → 22213 bytes)
@@ -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
binary file changed (absent → 587 bytes)
binary file changed (absent → 654 bytes)
@@ -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Ï>RA0Þ«ïC6åU
binary file changed (absent → 662 bytes)
binary file changed (absent → 557 bytes)
binary file changed (absent → 647 bytes)
@@ -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[
@@ -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
binary file changed (absent → 428 bytes)
binary file changed (absent → 563 bytes)
binary file changed (absent → 587 bytes)
binary file changed (absent → 656 bytes)
@@ -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
@@ -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
@@ -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Ô
binary file changed (absent → 581 bytes)
binary file changed (absent → 737 bytes)
binary file changed (absent → 593 bytes)
binary file changed (absent → 582 bytes)
binary file changed (absent → 647 bytes)
binary file changed (absent → 590 bytes)
binary file changed (absent → 655 bytes)
binary file changed (absent → 678 bytes)
binary file changed (absent → 445 bytes)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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óÎ|
@@ -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
@@ -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
@@ -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óÎ|
@@ -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
@@ -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
@@ -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$!×ýox»ø-ØyG^·½^
@@ -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
@@ -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
@@ -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óÎ|