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 -- * Simplified vault encryptor (credentials captured in closure)
12 , VaultEncryptor (..)
13 , ageVaultEncryptor
14 , mkMockVaultEncryptor
15 ) where
16
17 import Data.Bits (xor)
18 import Data.ByteString (ByteString)
19 import Data.ByteString qualified as BS
20 import Data.Text (Text)
21 import Data.Text qualified as T
22 import Data.Text.Encoding qualified as TE
23 import System.Process.Typed
24
25 -- | Errors that can arise from vault operations.
26 data VaultError
27 = VaultLocked
28 | VaultNotFound
29 | VaultCorrupted Text
30 | AgeError Text -- ^ stderr from age subprocess
31 | VaultAlreadyExists
32 | AgeNotInstalled Text -- ^ message with install hint
33 deriving stock (Show, Eq)
34
35 -- | An age public-key recipient (e.g. "age1..." or "age-plugin-yubikey-...").
36 newtype AgeRecipient = AgeRecipient Text
37 deriving stock (Show, Eq)
38
39 -- | An age identity: a file path or plugin string.
40 newtype AgeIdentity = AgeIdentity Text
41 deriving stock (Show, Eq)
42
43 -- | Handle for age encrypt/decrypt operations.
44 -- Use 'mkAgeEncryptor' for the real subprocess implementation,
45 -- or 'mkMockAgeEncryptor' in tests.
46 data AgeEncryptor = AgeEncryptor
47 { _ae_encrypt :: AgeRecipient -> ByteString -> IO (Either VaultError ByteString)
48 , _ae_decrypt :: AgeIdentity -> ByteString -> IO (Either VaultError ByteString)
49 }
50
51 -- | Construct a real 'AgeEncryptor' that shells out to the @age@ binary.
52 -- Performs a preflight @age --version@ check; returns
53 -- @Left (AgeNotInstalled hint)@ if the binary is not on PATH.
54 mkAgeEncryptor :: IO (Either VaultError AgeEncryptor)
55 mkAgeEncryptor = do
56 versionResult <- runProcess (proc "age" ["--version"])
57 case versionResult of
58 ExitFailure _ ->
59 pure (Left (AgeNotInstalled "Install age from https://age-encryption.org"))
60 ExitSuccess ->
61 pure (Right enc)
62 where
63 enc :: AgeEncryptor
64 enc = AgeEncryptor
65 { _ae_encrypt = \(AgeRecipient recipient) plaintext -> do
66 let cfg = setStdin (byteStringInput (BS.fromStrict plaintext))
67 $ proc "age" ["--encrypt", "--recipient", textToStr recipient]
68 (exitCode, out, err) <- readProcess cfg
69 case exitCode of
70 ExitSuccess -> pure (Right (BS.toStrict out))
71 ExitFailure _ -> pure (Left (AgeError (TE.decodeUtf8 (BS.toStrict err))))
72 , _ae_decrypt = \(AgeIdentity identity) ciphertext -> do
73 let cfg = setStdin (byteStringInput (BS.fromStrict ciphertext))
74 $ proc "age" ["--decrypt", "--identity", textToStr identity]
75 (exitCode, out, err) <- readProcess cfg
76 case exitCode of
77 ExitSuccess -> pure (Right (BS.toStrict out))
78 ExitFailure _ -> pure (Left (AgeError (TE.decodeUtf8 (BS.toStrict err))))
79 }
80
81 textToStr :: Text -> String
82 textToStr = T.unpack
83
84 -- | A mock 'AgeEncryptor' that XORs each byte with @0xAB@.
85 -- No real @age@ binary required — suitable for unit tests.
86 mkMockAgeEncryptor :: AgeEncryptor
87 mkMockAgeEncryptor = AgeEncryptor
88 { _ae_encrypt = \_recipient plaintext -> pure (Right (mockXor plaintext))
89 , _ae_decrypt = \_identity ciphertext -> pure (Right (mockXor ciphertext))
90 }
91 where
92 mockXor :: ByteString -> ByteString
93 mockXor = BS.map (`xor` 0xAB)
94
95 -- | A mock 'AgeEncryptor' that always returns the given error.
96 -- Useful for testing error-path handling in vault operations.
97 mkFailingAgeEncryptor :: VaultError -> AgeEncryptor
98 mkFailingAgeEncryptor err = AgeEncryptor
99 { _ae_encrypt = \_ _ -> pure (Left err)
100 , _ae_decrypt = \_ _ -> pure (Left err)
101 }
102
103 -- | Simplified encryptor: credentials are captured in the closure.
104 -- Replaces the explicit AgeRecipient/AgeIdentity arguments at call sites.
105 data VaultEncryptor = VaultEncryptor
106 { _ve_encrypt :: ByteString -> IO (Either VaultError ByteString)
107 , _ve_decrypt :: ByteString -> IO (Either VaultError ByteString)
108 }
109
110 -- | Create a 'VaultEncryptor' from an 'AgeEncryptor' with specific recipient/identity.
111 ageVaultEncryptor :: AgeEncryptor -> Text -> Text -> VaultEncryptor
112 ageVaultEncryptor enc recipient identity = VaultEncryptor
113 { _ve_encrypt = _ae_encrypt enc (AgeRecipient recipient)
114 , _ve_decrypt = _ae_decrypt enc (AgeIdentity identity)
115 }
116
117 -- | A mock 'VaultEncryptor' for unit tests (XOR like 'mkMockAgeEncryptor').
118 mkMockVaultEncryptor :: VaultEncryptor
119 mkMockVaultEncryptor = VaultEncryptor
120 { _ve_encrypt = pure . Right . mockXor
121 , _ve_decrypt = pure . Right . mockXor
122 }
123 where
124 mockXor :: ByteString -> ByteString
125 mockXor = BS.map (`xor` 0xAB)