packages feed

hopenpgp-tools-0.25.1: HOpenPGP/Tools/Common/WKD.hs

-- WKD.hs: hOpenPGP key tool
-- Copyright © 2026  Clint Adams
--
-- vim: softtabstop=4:shiftwidth=4:expandtab
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as
-- published by the Free Software Foundation, either version 3 of the
-- License, or (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program.  If not, see <http://www.gnu.org/licenses/>.

{-# LANGUAGE OverloadedStrings #-}

module HOpenPGP.Tools.Common.WKD
  ( fetchKeys
  , parseMailbox
  ) where

import qualified Codec.Encryption.OpenPGP.ASCIIArmor as AA
import Codec.Encryption.OpenPGP.ASCIIArmor.Types
  ( Armor(Armor)
  , ArmorType(ArmorPublicKeyBlock)
  )
import Codec.Encryption.OpenPGP.Types (TKUnknown(..))
import Control.Arrow ((&&&))
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Except (ExceptT(..), throwE)
import qualified Crypto.Hash as CH
import qualified Crypto.Hash.Algorithms as CHA
import Data.Binary (get)
import qualified Data.ByteArray as BA
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as BC8
import qualified Data.ByteString.Lazy as BL
import Data.Bits ((.&.), (.|.), shiftL, shiftR)
import Data.Conduit ((.|), runConduitRes)
import qualified Data.Conduit.Binary as CB
import qualified Data.Conduit.List as CL
import Data.Conduit.OpenPGP.Keyring (conduitToTKsDropping)
import Data.Conduit.Serialization.Binary (conduitGet)
import Data.Either (rights)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Time.Clock.POSIX (getPOSIXTime)
import Data.Word (Word8)
import HOpenPGP.Tools.Common.HKP (FetchValidationMethod(..))
import HOpenPGP.Tools.Common.TKUtils (processTK)
import Network.HTTP.Client
  ( Manager
  , Response(..)
  , httpLbs
  , newManager
  , parseUrlThrow
  , setQueryString
  )
import Network.HTTP.Client.TLS (tlsManagerSettings)
import Network.HTTP.Types.Status (ok200)

fetchKeys ::
     FetchValidationMethod
  -> Text
  -> ExceptT String IO [TKUnknown]
fetchKeys fvm mailbox = do
  parsedMailbox <- ExceptT . return $ parseMailbox mailbox
  manager <- liftIO $ newManager tlsManagerSettings
  response <- fetchWKD manager parsedMailbox
  body <-
    if responseStatus response == ok200
      then return (responseBody response)
      else throwE ("HTTP status: " ++ show (responseStatus response))
  validateAndFilterKeys fvm parsedMailbox body

parseMailbox :: Text -> Either String (Text, Text)
parseMailbox rawMailbox =
  let mailbox = T.strip rawMailbox
      parts = T.splitOn "@" mailbox
   in case parts of
        [localPart, domain]
          | T.null localPart -> Left "mailbox local part cannot be empty"
          | T.null domain -> Left "mailbox domain cannot be empty"
          | T.any (== ' ') mailbox -> Left "mailbox cannot contain spaces"
          | otherwise -> Right (localPart, T.toLower domain)
        _ -> Left "mailbox must contain exactly one @"

fetchWKD ::
     Manager
  -> (Text, Text)
  -> ExceptT String IO (Response BL.ByteString)
fetchWKD manager (localPart, domain) = do
  let localPartLower = T.toLower localPart
      hu = BC8.unpack . zbase32 . sha1 . TE.encodeUtf8 $ localPartLower
      advancedUrl =
        "https://openpgpkey." <>
        T.unpack domain <>
        "/.well-known/openpgpkey/" <> T.unpack domain <> "/hu/" <> hu
      directUrl =
        "https://" <> T.unpack domain <> "/.well-known/openpgpkey/hu/" <> hu
      mailboxParam = TE.encodeUtf8 localPartLower
      withMailbox req = setQueryString [("l", Just mailboxParam)] req
  advancedRequest <- liftIO $ parseUrlThrow advancedUrl
  advancedResponse <- liftIO $ httpLbs (withMailbox advancedRequest) manager
  if responseStatus advancedResponse == ok200
    then return advancedResponse
    else do
      directRequest <- liftIO $ parseUrlThrow directUrl
      liftIO $ httpLbs (withMailbox directRequest) manager

validateAndFilterKeys ::
     FetchValidationMethod
  -> (Text, Text)
  -> BL.ByteString
  -> ExceptT String IO [TKUnknown]
validateAndFilterKeys fvm mailbox body = do
  keys <- decodeWkdResponse body
  cpt <- liftIO getPOSIXTime
  let processedKeys = rights $ map (uncurry (liftA2 (,)) . (pure &&& processTK (Just cpt))) keys
      mailboxFiltered = filter (mailboxMatchesKey mailbox . snd) processedKeys
  return $
    map fst $
    case fvm of
      AnySelfSigned -> processedKeys
      MatchPrimaryKeyFingerprint -> mailboxFiltered
      MatchPrimaryOrAnySubkeyFingerprint -> mailboxFiltered

decodeWkdResponse :: BL.ByteString -> ExceptT String IO [TKUnknown]
decodeWkdResponse body =
  if isArmored body
    then decodeArmored body
    else decodeBinary body

decodeBinary :: BL.ByteString -> ExceptT String IO [TKUnknown]
decodeBinary bytes =
  liftIO . runConduitRes $
  CB.sourceLbs bytes .| conduitGet get .| conduitToTKsDropping .| CL.consume

decodeArmored :: BL.ByteString -> ExceptT String IO [TKUnknown]
decodeArmored larmors = do
  bytestrings <- ExceptT . return $ fmap (mconcat . map armorToBS) (AA.decodeLazy larmors)
  liftIO . runConduitRes $
    CB.sourceLbs bytestrings .| conduitGet get .| conduitToTKsDropping .| CL.consume
  where
    armorToBS (Armor ArmorPublicKeyBlock _ bs) = bs
    armorToBS _ = mempty

isArmored :: BL.ByteString -> Bool
isArmored =
  BC8.isPrefixOf "-----BEGIN PGP PUBLIC KEY BLOCK-----" . BL.toStrict . BL.take 40

mailboxMatchesKey :: (Text, Text) -> TKUnknown -> Bool
mailboxMatchesKey (localPart, domain) tk =
  let mailbox = T.toLower (localPart <> "@" <> domain)
      bracketedMailbox = "<" <> mailbox <> ">"
   in any
        (\uid -> let lowered = T.toLower uid in lowered == mailbox || bracketedMailbox `T.isInfixOf` lowered)
        (map fst (_tkuUIDs tk))

sha1 :: B.ByteString -> B.ByteString
sha1 bs = BA.convert (CH.hashWith CHA.SHA1 bs :: CH.Digest CHA.SHA1)

zbase32 :: B.ByteString -> B.ByteString
zbase32 = BC8.pack . encodeZBase32 . B.unpack

encodeZBase32 :: [Word8] -> String
encodeZBase32 = go 0 0
  where
    alphabet = "ybndrfg8ejkmcpqxot1uwisza345h769"
    pick i = alphabet !! i
    go _ 0 [] = []
    go acc bits [] =
      [pick (fromIntegral (((acc `shiftL` (5 - bits)) .&. 31) :: Int))]
    go acc bits (x:xs)
      | bits >= 5 =
        pick (fromIntegral (((acc `shiftR` (bits - 5)) .&. 31) :: Int)) :
        go acc (bits - 5) (x : xs)
      | otherwise = go ((acc `shiftL` 8) .|. fromIntegral x) (bits + 8) xs