never executed always true always false
1 module PureClaw.Security.Vault.Age
2 ( -- * Error type
3 VaultError (..)
4 -- * Age encryptor handle
5 , AgeEncryptor (..)
6 , AgeRecipient (..)
7 , AgeIdentity (..)
8 , mkAgeEncryptor
9 , mkMockAgeEncryptor
10 , mkFailingAgeEncryptor
11 ) where
12
13 import Data.Bits (xor)
14 import Data.ByteString (ByteString)
15 import Data.ByteString qualified as BS
16 import Data.Text (Text)
17 import Data.Text qualified as T
18 import Data.Text.Encoding qualified as TE
19 import System.Process.Typed
20
21 -- | Errors that can arise from vault operations.
22 data VaultError
23 = VaultLocked
24 | VaultNotFound
25 | VaultCorrupted Text
26 | AgeError Text -- ^ stderr from age subprocess
27 | VaultAlreadyExists
28 | AgeNotInstalled Text -- ^ message with install hint
29 deriving stock (Show, Eq)
30
31 -- | An age public-key recipient (e.g. "age1..." or "age-plugin-yubikey-...").
32 newtype AgeRecipient = AgeRecipient Text
33 deriving stock (Show, Eq)
34
35 -- | An age identity: a file path or plugin string.
36 newtype AgeIdentity = AgeIdentity Text
37 deriving stock (Show, Eq)
38
39 -- | Handle for age encrypt/decrypt operations.
40 -- Use 'mkAgeEncryptor' for the real subprocess implementation,
41 -- or 'mkMockAgeEncryptor' in tests.
42 data AgeEncryptor = AgeEncryptor
43 { _ae_encrypt :: AgeRecipient -> ByteString -> IO (Either VaultError ByteString)
44 , _ae_decrypt :: AgeIdentity -> ByteString -> IO (Either VaultError ByteString)
45 }
46
47 -- | Construct a real 'AgeEncryptor' that shells out to the @age@ binary.
48 -- Performs a preflight @age --version@ check; returns
49 -- @Left (AgeNotInstalled hint)@ if the binary is not on PATH.
50 mkAgeEncryptor :: IO (Either VaultError AgeEncryptor)
51 mkAgeEncryptor = do
52 versionResult <- runProcess (proc "age" ["--version"])
53 case versionResult of
54 ExitFailure _ ->
55 pure (Left (AgeNotInstalled "Install age from https://age-encryption.org"))
56 ExitSuccess ->
57 pure (Right enc)
58 where
59 enc :: AgeEncryptor
60 enc = AgeEncryptor
61 { _ae_encrypt = \(AgeRecipient recipient) plaintext -> do
62 let cfg = setStdin (byteStringInput (BS.fromStrict plaintext))
63 $ proc "age" ["--encrypt", "--recipient", textToStr recipient]
64 (exitCode, out, err) <- readProcess cfg
65 case exitCode of
66 ExitSuccess -> pure (Right (BS.toStrict out))
67 ExitFailure _ -> pure (Left (AgeError (TE.decodeUtf8 (BS.toStrict err))))
68 , _ae_decrypt = \(AgeIdentity identity) ciphertext -> do
69 let cfg = setStdin (byteStringInput (BS.fromStrict ciphertext))
70 $ proc "age" ["--decrypt", "--identity", textToStr identity]
71 (exitCode, out, err) <- readProcess cfg
72 case exitCode of
73 ExitSuccess -> pure (Right (BS.toStrict out))
74 ExitFailure _ -> pure (Left (AgeError (TE.decodeUtf8 (BS.toStrict err))))
75 }
76
77 textToStr :: Text -> String
78 textToStr = T.unpack
79
80 -- | A mock 'AgeEncryptor' that XORs each byte with @0xAB@.
81 -- No real @age@ binary required — suitable for unit tests.
82 mkMockAgeEncryptor :: AgeEncryptor
83 mkMockAgeEncryptor = AgeEncryptor
84 { _ae_encrypt = \_recipient plaintext -> pure (Right (mockXor plaintext))
85 , _ae_decrypt = \_identity ciphertext -> pure (Right (mockXor ciphertext))
86 }
87 where
88 mockXor :: ByteString -> ByteString
89 mockXor = BS.map (`xor` 0xAB)
90
91 -- | A mock 'AgeEncryptor' that always returns the given error.
92 -- Useful for testing error-path handling in vault operations.
93 mkFailingAgeEncryptor :: VaultError -> AgeEncryptor
94 mkFailingAgeEncryptor err = AgeEncryptor
95 { _ae_encrypt = \_ _ -> pure (Left err)
96 , _ae_decrypt = \_ _ -> pure (Left err)
97 }