diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,5 @@
+# ChangeLog for ech-config
+
+## 0.0.0
+
+* Initial release.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,29 @@
+Copyright (c) 2025, IIJ Innovation Institute Inc.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+  * Redistributions of source code must retain the above copyright
+    notice, this list of conditions and the following disclaimer.
+  * Redistributions in binary form must reproduce the above copyright
+    notice, this list of conditions and the following disclaimer in
+    the documentation and/or other materials provided with the
+    distribution.
+  * Neither the name of the copyright holders nor the names of its
+    contributors may be used to endorse or promote products derived
+    from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
diff --git a/Network/TLS/ECH/Config.hs b/Network/TLS/ECH/Config.hs
new file mode 100644
--- /dev/null
+++ b/Network/TLS/ECH/Config.hs
@@ -0,0 +1,360 @@
+{-# LANGUAGE RecordWildCards #-}
+
+-- | Types for Configuration of Encrypted Client Hello.
+module Network.TLS.ECH.Config (
+    -- * Types
+    ECHConfigList,
+    ECHConfig (..),
+    ECHConfigContents (..),
+    HpkeKeyConfig (..),
+    ConfigId,
+    EncodedServerPublicKey (..),
+    HpkeSymmetricCipherSuite (..),
+    ECHConfigExtensionType,
+    ECHConfigExtension (..),
+
+    -- * ECH configuration list
+    decodeECHConfigList,
+    encodeECHConfigList,
+    loadECHConfigList,
+    loadECHSecretKeys,
+
+    -- * ECH configuration
+    decodeECHConfig,
+    encodeECHConfig,
+
+    -- * Low level
+    getECHConfigList,
+    putECHConfigList,
+    sizeOfECHConfigList,
+    getECHConfig,
+    putECHConfig,
+    sizeOfECHConfig,
+) where
+
+import qualified Control.Exception as E
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Base16 as B16
+import qualified Data.ByteString.Char8 as C8
+import Data.Char (isDigit)
+import Data.Word
+import Network.ByteOrder
+import System.FilePath (takeFileName)
+import System.IO.Unsafe (unsafePerformIO)
+import Text.Printf (printf)
+
+----------------------------------------------------------------
+
+class SizeOf a where
+    sizeof :: a -> Int
+
+----------------------------------------------------------------
+
+-- | Type for cipher suite.
+data HpkeSymmetricCipherSuite = HpkeSymmetricCipherSuite
+    { kdf_id :: Word16
+    , aead_id :: Word16
+    }
+    deriving (Eq, Ord)
+
+instance SizeOf HpkeSymmetricCipherSuite where
+    sizeof _ = 4
+
+instance Show HpkeSymmetricCipherSuite where
+    show HpkeSymmetricCipherSuite{..} = "(" ++ showKDF_ID kdf_id ++ "," ++ showAEAD_ID aead_id ++ ")"
+      where
+        showKDF_ID 0x0000 = "KDF-Reserved"
+        showKDF_ID 0x0001 = "HKDF-SHA256"
+        showKDF_ID 0x0002 = "HKDF-SHA384"
+        showKDF_ID 0x0003 = "HKDF-SHA512"
+        showKDF_ID x = "KDF_ID " ++ printf "0x04" x
+        showAEAD_ID 0x0000 = "AEAD_Reserved"
+        showAEAD_ID 0x0001 = "AES-128-GCM"
+        showAEAD_ID 0x0002 = "AES-256-GCM"
+        showAEAD_ID 0x0003 = "ChaCha20Poly1305"
+        showAEAD_ID 0xFFFF = "Export-only"
+        showAEAD_ID x = "AEAD_ID " ++ printf "0x04" x
+
+getHpkeSymmetricCipherSuite :: ReadBuffer -> IO HpkeSymmetricCipherSuite
+getHpkeSymmetricCipherSuite rbuf =
+    HpkeSymmetricCipherSuite <$> read16 rbuf <*> read16 rbuf
+
+putHpkeSymmetricCipherSuite :: WriteBuffer -> HpkeSymmetricCipherSuite -> IO ()
+putHpkeSymmetricCipherSuite wbuf HpkeSymmetricCipherSuite{..} = do
+    write16 wbuf kdf_id
+    write16 wbuf aead_id
+
+----------------------------------------------------------------
+
+-- | Encoded public key.
+newtype EncodedServerPublicKey = EncodedServerPublicKey ByteString
+    deriving (Eq, Ord)
+
+instance Show EncodedServerPublicKey where
+    show (EncodedServerPublicKey bs) = "\"" ++ C8.unpack (B16.encode bs) ++ "\""
+
+instance SizeOf EncodedServerPublicKey where
+    sizeof (EncodedServerPublicKey bs) = 2 + BS.length bs
+
+-- | Configuration identifier.
+type ConfigId = Word8
+
+-- | Key configuration.
+data HpkeKeyConfig = HpkeKeyConfig
+    { config_id :: ConfigId
+    , kem_id :: Word16
+    , public_key :: EncodedServerPublicKey
+    , cipher_suites :: [HpkeSymmetricCipherSuite]
+    }
+    deriving (Eq, Ord)
+
+instance SizeOf HpkeKeyConfig where
+    sizeof HpkeKeyConfig{..} = 5 + sizeof public_key + sum (map sizeof cipher_suites)
+
+instance Show HpkeKeyConfig where
+    show HpkeKeyConfig{..} =
+        "{"
+            ++ show config_id
+            ++ ", "
+            ++ showKEM_ID kem_id
+            ++ ", "
+            ++ show public_key
+            ++ ", "
+            ++ show cipher_suites
+            ++ "}"
+      where
+        showKEM_ID 0x0000 = "KEM_Reserved"
+        showKEM_ID 0x0010 = "DHKEM(P-256, HKDF-SHA256)"
+        showKEM_ID 0x0011 = "DHKEM(P-384, HKDF-SHA384)"
+        showKEM_ID 0x0012 = "DHKEM(P-521, HKDF-SHA512)"
+        showKEM_ID 0x0020 = "DHKEM(X25519, HKDF-SHA256)"
+        showKEM_ID 0x0021 = "DHKEM(X448, HKDF-SHA512)"
+        showKEM_ID x = "KEM_ID " ++ printf "0x04" x
+
+getHpkeKeyConfig :: ReadBuffer -> IO HpkeKeyConfig
+getHpkeKeyConfig rbuf = do
+    cfid <- read8 rbuf
+    kid <- read16 rbuf
+    pk <- EncodedServerPublicKey <$> getOpaque16 rbuf
+    cs <- getList16 rbuf getHpkeSymmetricCipherSuite
+    return $ HpkeKeyConfig cfid kid pk cs
+
+putHpkeKeyConfig :: WriteBuffer -> HpkeKeyConfig -> IO ()
+putHpkeKeyConfig wbuf HpkeKeyConfig{..} = do
+    write8 wbuf config_id
+    write16 wbuf kem_id
+    let EncodedServerPublicKey pk = public_key
+    putOpaque16 wbuf pk
+    putList16 wbuf putHpkeSymmetricCipherSuite cipher_suites
+
+----------------------------------------------------------------
+
+-- | Extension type.
+type ECHConfigExtensionType = Word16
+
+-- | Raw extension.
+data ECHConfigExtension = ECHConfigExtension
+    { ece_type :: ECHConfigExtensionType
+    , ece_data :: ByteString
+    }
+    deriving (Eq, Ord, Show)
+
+instance SizeOf ECHConfigExtension where
+    sizeof ECHConfigExtension{..} = 4 + BS.length ece_data
+
+getECHConfigExtension :: ReadBuffer -> IO ECHConfigExtension
+getECHConfigExtension rbuf = do
+    typ <- read16 rbuf
+    ext <- getOpaque16 rbuf
+    return $ ECHConfigExtension typ ext
+
+putECHConfigExtension :: WriteBuffer -> ECHConfigExtension -> IO ()
+putECHConfigExtension wbuf ECHConfigExtension{..} = do
+    write16 wbuf ece_type
+    putOpaque16 wbuf ece_data
+
+----------------------------------------------------------------
+
+data ECHConfigContents = ECHConfigContents
+    { key_config :: HpkeKeyConfig
+    , maximum_name_length :: Word8
+    , public_name :: String
+    , extensions :: [ECHConfigExtension]
+    }
+    deriving (Eq, Ord, Show)
+
+instance SizeOf ECHConfigContents where
+    sizeof ECHConfigContents{..} =
+        sizeof key_config
+            + 4
+            + BS.length (C8.pack public_name)
+            + sum (map sizeof extensions)
+
+getECHConfigContents :: ReadBuffer -> IO ECHConfigContents
+getECHConfigContents rbuf = do
+    kcf <- getHpkeKeyConfig rbuf
+    mnl <- read8 rbuf
+    pn <- C8.unpack <$> getOpaque8 rbuf
+    exts <- getList16 rbuf getECHConfigExtension
+    return $ ECHConfigContents kcf mnl pn exts
+
+putECHConfigContents :: WriteBuffer -> ECHConfigContents -> IO ()
+putECHConfigContents wbuf ECHConfigContents{..} = do
+    putHpkeKeyConfig wbuf key_config
+    write8 wbuf maximum_name_length
+    putOpaque8 wbuf $ C8.pack public_name
+    putList16 wbuf putECHConfigExtension extensions
+
+----------------------------------------------------------------
+
+-- | Type for configuration of encrypted client hello.
+data ECHConfig = ECHConfig
+    { contents :: ECHConfigContents
+    }
+    deriving (Eq, Ord)
+
+instance SizeOf ECHConfig where
+    sizeof ECHConfig{..} = 4 + sizeof contents
+
+instance Show ECHConfig where
+    show ECHConfig{..} = show contents
+
+getECHConfig :: ReadBuffer -> IO ECHConfig
+getECHConfig rbuf = do
+    _ver <- read16 rbuf
+    _len <- read16 rbuf
+    ECHConfig <$> getECHConfigContents rbuf
+
+putECHConfig :: WriteBuffer -> ECHConfig -> IO ()
+putECHConfig wbuf ECHConfig{..} = do
+    write16 wbuf 0xfe0d
+    withLength16 wbuf $ putECHConfigContents wbuf contents
+
+sizeOfECHConfig :: ECHConfig -> Int
+sizeOfECHConfig cnf = sizeof cnf
+
+-- | Encoder for 'ECHConfig'.
+encodeECHConfig :: ECHConfig -> ByteString
+encodeECHConfig cnf = unsafePerformIO $ withWriteBuffer siz $ \wbuf -> putECHConfig wbuf cnf
+  where
+    siz = sizeOfECHConfig cnf
+
+-- | Decoder for 'ECHConfig'.
+decodeECHConfig :: ByteString -> Maybe ECHConfig
+decodeECHConfig bs =
+    unsafePerformIO $
+        E.handle handler $
+            withReadBuffer bs $
+                \rbuf -> Just <$> getECHConfig rbuf
+  where
+    handler (E.SomeException _) = return Nothing
+
+----------------------------------------------------------------
+
+-- | A list of 'ECHConfig'.
+type ECHConfigList = [ECHConfig]
+
+getECHConfigList :: ReadBuffer -> IO [ECHConfig]
+getECHConfigList rbuf = getList16 rbuf getECHConfig
+
+putECHConfigList :: WriteBuffer -> [ECHConfig] -> IO ()
+putECHConfigList wbuf configs =
+    putList16 wbuf putECHConfig configs
+
+sizeOfECHConfigList :: [ECHConfig] -> Int
+sizeOfECHConfigList configs = sum (map sizeOfECHConfig configs) + 2
+
+-- | Encoder for 'ECHConfigList'.
+encodeECHConfigList :: [ECHConfig] -> ByteString
+encodeECHConfigList configs = unsafePerformIO $ withWriteBuffer siz $ \wbuf ->
+    putECHConfigList wbuf configs
+  where
+    siz = sizeOfECHConfigList configs
+
+-- | Decoder for 'ECHConfigList'.
+decodeECHConfigList :: ByteString -> Maybe [ECHConfig]
+decodeECHConfigList bs =
+    unsafePerformIO $
+        E.handle handler $
+            withReadBuffer bs $
+                \rbuf -> Just <$> getECHConfigList rbuf
+  where
+    handler (E.SomeException _) = return Nothing
+
+-- | Loading the wire format of 'ECHConfigList' and
+--   decode it into 'ECHConfigList'.
+loadECHConfigList :: FilePath -> IO [ECHConfig]
+loadECHConfigList file = do
+    bs <- C8.readFile file
+    withReadBuffer bs $ getECHConfigList
+
+-- | Loading secret keys stored in files whose names
+-- are "\<num\>.key".
+--
+-- > loadECHSecretKeys ["0.key", "1.key"]
+loadECHSecretKeys :: [FilePath] -> IO [(ConfigId, ByteString)]
+loadECHSecretKeys files = mapM loadECHSecretKey files
+  where
+    loadECHSecretKey file = do
+        let numstr = takeWhile isDigit $ takeFileName file
+            key = read numstr :: ConfigId
+        val <- C8.readFile file
+        return (key, val)
+
+----------------------------------------------------------------
+
+getOpaque8 :: ReadBuffer -> IO ByteString
+getOpaque8 rbuf = do
+    len <- fromIntegral <$> read8 rbuf
+    extractByteString rbuf len
+
+putOpaque8 :: WriteBuffer -> ByteString -> IO ()
+putOpaque8 wbuf x = do
+    write8 wbuf $ fromIntegral $ BS.length x
+    copyByteString wbuf x
+
+getOpaque16 :: ReadBuffer -> IO ByteString
+getOpaque16 rbuf = do
+    len <- fromIntegral <$> read16 rbuf
+    extractByteString rbuf len
+
+putOpaque16 :: WriteBuffer -> ByteString -> IO ()
+putOpaque16 wbuf x = do
+    write16 wbuf $ fromIntegral $ BS.length x
+    copyByteString wbuf x
+
+getList16 :: ReadBuffer -> (ReadBuffer -> IO a) -> IO [a]
+getList16 rbuf parer = do
+    len <- fromIntegral <$> read16 rbuf
+    cur <- position rbuf
+    let lim = cur + len
+    loop lim id
+  where
+    loop lim build = do
+        cur <- position rbuf
+        if cur < lim
+            then do
+                x <- parer rbuf
+                loop lim (build . (x :))
+            else return $ build []
+
+withLength16 :: WriteBuffer -> IO () -> IO ()
+withLength16 wbuf builder = do
+    lenpos <- position wbuf
+    write16 wbuf 0
+    old <- position wbuf
+    builder
+    new <- position wbuf
+    let len = new - old
+    ff wbuf (lenpos - new)
+    write16 wbuf $ fromIntegral len
+    ff wbuf len
+
+putList16 :: WriteBuffer -> (WriteBuffer -> a -> IO ()) -> [a] -> IO ()
+putList16 wbuf builder xxs =
+    withLength16 wbuf $ loop xxs
+  where
+    loop [] = return ()
+    loop (x : xs) = do
+        builder wbuf x
+        loop xs
diff --git a/ech-config.cabal b/ech-config.cabal
new file mode 100644
--- /dev/null
+++ b/ech-config.cabal
@@ -0,0 +1,53 @@
+cabal-version:      >=1.10
+name:               ech-config
+version:            0.0.0
+license:            BSD3
+license-file:       LICENSE
+maintainer:         kazu@iij.ad.jp
+author:             Kazu Yamamoto
+synopsis:           Config for TLS Encrypted Client Hello
+description:
+    Config types for TLS Encrypted Client Hello to glue DNS and TLS
+
+category:           Network
+build-type:         Simple
+extra-source-files: ChangeLog.md
+
+flag devel
+    description: Development commands
+    default:     False
+
+library
+    exposed-modules:    Network.TLS.ECH.Config
+    default-language:   Haskell2010
+    default-extensions: Strict StrictData
+    ghc-options:        -Wall
+    build-depends:
+        base >=4.7 && <5,
+        base16-bytestring,
+        bytestring,
+        filepath,
+        network-byte-order
+
+executable ech-gen
+    main-is:            ech-gen.hs
+    hs-source-dirs:     util
+    default-language:   Haskell2010
+    default-extensions: Strict StrictData
+    ghc-options:        -Wall -threaded -rtsopts
+    build-depends:
+        base >=4.9 && <5,
+        bytestring,
+        base64-bytestring,
+        ech-config,
+        hpke
+
+    if flag(devel)
+
+    else
+        buildable: False
+
+source-repository head
+    type:     git
+    location: https://github.com/haskell-tls/hs-tls
+    subdir:   tls
diff --git a/util/ech-gen.hs b/util/ech-gen.hs
new file mode 100644
--- /dev/null
+++ b/util/ech-gen.hs
@@ -0,0 +1,128 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main where
+
+import Crypto.HPKE (
+    AEAD_ID (..),
+    EncodedPublicKey (..),
+    EncodedSecretKey (..),
+    KDF_ID (..),
+    KEM_ID (..),
+ )
+import Crypto.HPKE.Internal (defaultHPKEMap, genKeyPair)
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Base64 as B64
+import Data.Word (Word8)
+import Network.TLS.ECH.Config
+import System.Environment
+
+kemId :: KEM_ID
+kemId = DHKEM_X25519_HKDF_SHA256
+
+kdfId :: KDF_ID
+kdfId = HKDF_SHA256
+
+aeadId :: AEAD_ID
+aeadId = AES_128_GCM
+
+mkConfig
+    :: Word8
+    -> String
+    -> KEM_ID
+    -> KDF_ID
+    -> AEAD_ID
+    -> EncodedServerPublicKey
+    -> ECHConfig
+mkConfig confid hostname kemid kdfid aeadid pkm =
+    ECHConfig
+        { contents =
+            ECHConfigContents
+                { key_config =
+                    HpkeKeyConfig
+                        { config_id = confid
+                        , kem_id = fromKEM_ID kemid
+                        , public_key = pkm
+                        , cipher_suites =
+                            [ HpkeSymmetricCipherSuite
+                                { kdf_id = fromKDF_ID kdfid
+                                , aead_id = fromAEAD_ID aeadid
+                                }
+                            ]
+                        }
+                , maximum_name_length = 0
+                , public_name = hostname
+                , extensions = []
+                }
+        }
+
+main :: IO ()
+main = do
+    args <- getArgs
+    case args of
+        [hostname, num] -> do
+            let confId = read num :: Word8
+            (EncodedPublicKey pkm, EncodedSecretKey skm) <-
+                genKeyPair defaultHPKEMap kemId
+            let config = mkConfig confId hostname kemId kdfId aeadId (EncodedServerPublicKey pkm)
+                configs = [config]
+            let encodedConfigList = encodeECHConfigList configs
+            let encodedConfig = BS.drop 2 encodedConfigList
+            print configs
+
+            let configfileR = hostname ++ ".raw"
+            BS.writeFile configfileR encodedConfigList
+
+            let configfileO = hostname ++ ".one"
+            BS.writeFile configfileO encodedConfig
+
+            let configfileB = hostname ++ ".b64"
+                configB64 = B64.encode encodedConfigList
+            BS.writeFile configfileB configB64
+
+            putStrLn "ECH config files:"
+            putStrLn $
+                "\t\""
+                    ++ configfileR
+                    ++ "\" for Haskell client/server, picotls client/server, BoringSSL client"
+            putStrLn $ "\t\"" ++ configfileO ++ "\" for BoringSSL server"
+            putStrLn $ "\t\"" ++ configfileB ++ "\" for DEfO OpenSSL client, NSS client"
+
+            let secfileR = num ++ ".raw"
+            BS.writeFile secfileR skm
+            let secfileP = num ++ ".pem"
+                secPEM =
+                    "-----BEGIN PRIVATE KEY-----\n"
+                        <> B64.encode (magic <> skm)
+                        <> "\n"
+                        <> "-----END PRIVATE KEY-----\n"
+            BS.writeFile secfileP secPEM
+            {-
+                        let secfileN = num ++ ".nss"
+                            x = magic <> skm -- FIXME: pk is neccessary, sigh.
+                            len = BS.length x
+                            (a, b) = len `divMod` 256
+                            y = BS.pack [fromIntegral a, fromIntegral b] <> x <> encodedConfigList
+                            z = B64.encode y
+                        BS.writeFile secfileN z
+            -}
+            let secfileO = hostname ++ num ++ ".pem"
+                secconfPEM =
+                    secPEM
+                        <> "-----BEGIN ECHCONFIG-----\n"
+                        <> configB64
+                        <> "\n"
+                        <> "-----END ECHCONFIG-----\n"
+            BS.writeFile secfileO secconfPEM
+
+            putStrLn "ECH server private key files:"
+            putStrLn $ "\t\"" ++ secfileR ++ "\" for Haskell server"
+            putStrLn $ "\t\"" ++ secfileP ++ "\" for picotls server"
+
+            putStrLn "ECH server private key/config files:"
+            putStrLn $ "\t\"" ++ secfileO ++ "\" for DEfO OpenSSL server"
+        _ -> putStrLn "ech-gen <public-server-name> <num>"
+
+-- RFC 8410
+magic :: ByteString
+magic = "\x30\x2e\x02\x01\x00\x30\x05\x06\x03\x2b\x65\x6e\x04\x22\x04\x20"
