packages feed

plexus-synapse-3.15.0: src/Synapse/Identity/Proof.hs

{-# LANGUAGE OverloadedStrings #-}

-- | IDENTITY-43: an agent's key and the proof it logs in with — IDENTITY-40's
-- "The wire", exactly.
--
-- A key is an Ed25519 seed (32 bytes); its principal is
-- @key:\<64 lowercase hex of the public key\>@. A proof is a bearer value
--
-- @
-- plexus-key.\<base64url-nopad of UTF-8 JSON\>
-- {"v":1,"key":"\<64 hex\>","aud":"\<ws url\>","agent":"\<id\>","nonce":"\<b64url\>","ts":\<unix s\>,"sig":"\<b64url\>"}
-- @
--
-- where @sig@ is Ed25519 over the UTF-8 bytes of exactly
-- @plexus-key-login/v1\\n\<aud\>\\n\<agent\>\\n\<nonce\>\\n\<ts\>@ (no trailing
-- newline). The JSON's fields go in that order, compact, so the same
-- inputs give the same proof on every side (Ed25519 signing is
-- deterministic): see the fixed vector in @test/IdentitySpec.hs@.
module Synapse.Identity.Proof
  ( -- * Keys
    Seed
  , seedFromBytes
  , seedBytes
  , generateSeed
  , publicKeyHex
  , principalOf

    -- * The proof
  , canonicalMessage
  , buildProof
  , newNonce
  , wsAudience
  , validAgent
  , validKeyName

    -- * Encodings
  , b64urlNoPad
  , hexText
  , decodeSeedText
  , encodeSeedText
  ) where

import qualified Crypto.PubKey.Ed25519 as Ed
import Crypto.Error (CryptoFailable(..))
import Crypto.Random (getRandomBytes)
import qualified Data.Aeson.Encoding as AE
import Data.Aeson ((.=))
import qualified Data.ByteArray as BA
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64 as B64
import qualified Data.ByteString.Base64.URL as B64U
import qualified Data.ByteString.Lazy as LBS
import Data.Char (isAsciiLower, isAsciiUpper, isDigit)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Text.Printf (printf)

-- | An Ed25519 seed: the 32 bytes a key is.
newtype Seed = Seed ByteString
  deriving (Eq)

instance Show Seed where
  show _ = "Seed <redacted>"

-- | A seed from its 32 bytes, or why these are not one.
seedFromBytes :: ByteString -> Either Text Seed
seedFromBytes b
  | BS.length b == 32 = Right (Seed b)
  | otherwise = Left $ "a key is 32 bytes, not " <> T.pack (show (BS.length b))

seedBytes :: Seed -> ByteString
seedBytes (Seed b) = b

-- | A new seed from the system's randomness.
generateSeed :: IO Seed
generateSeed = Seed <$> getRandomBytes 32

secretOf :: Seed -> Ed.SecretKey
secretOf (Seed b) = case Ed.secretKey b of
  CryptoPassed sk -> sk
  CryptoFailed e -> error ("Synapse.Identity.Proof: " <> show e)  -- length is checked at construction

publicOf :: Seed -> Ed.PublicKey
publicOf = Ed.toPublic . secretOf

-- | The public key, 64 lowercase hex.
publicKeyHex :: Seed -> Text
publicKeyHex = hexText . BA.convert . publicOf

-- | @key:\<64 hex\>@ — the principal a key is.
principalOf :: Seed -> Text
principalOf s = "key:" <> publicKeyHex s

-- | The bytes the signature covers, as text:
-- @plexus-key-login/v1\\n\<aud\>\\n\<agent\>\\n\<nonce\>\\n\<ts\>@.
canonicalMessage :: Text -> Text -> Text -> Integer -> Text
canonicalMessage aud agent nonce ts =
  T.intercalate "\n" ["plexus-key-login/v1", aud, agent, nonce, T.pack (show ts)]

-- | The proof for this key, audience (the ws URL dialed), agent, nonce (its
-- raw bytes; 32 of them) and time (unix seconds).
buildProof :: Seed -> Text -> Text -> ByteString -> Integer -> Text
buildProof seed aud agent nonceRaw ts =
  let sk = secretOf seed
      pk = Ed.toPublic sk
      nonce = b64urlNoPad nonceRaw
      msg = TE.encodeUtf8 (canonicalMessage aud agent nonce ts)
      sig = Ed.sign sk pk msg
      json = AE.encodingToLazyByteString $ AE.pairs $ mconcat
        [ "v" .= (1 :: Int)
        , "key" .= hexText (BA.convert pk)
        , "aud" .= aud
        , "agent" .= agent
        , "nonce" .= nonce
        , "ts" .= ts
        , "sig" .= b64urlNoPad (BA.convert sig)
        ]
  in "plexus-key." <> b64urlNoPad (LBS.toStrict json)

-- | 32 random bytes for a proof's nonce.
newNonce :: IO ByteString
newNonce = getRandomBytes 32

-- | The URL a proof's @aud@ names: the socket synapse dials,
-- @ws://HOST:PORT@ (SCAFFOLD-21: behind TLS, @wss://HOST@ —
-- 'Synapse.Endpoint.audience' says which for an address).
wsAudience :: Text -> Int -> Text
wsAudience host port = "ws://" <> host <> ":" <> T.pack (show port)

-- | An agent id: 1–64 of @[A-Za-z0-9._-]@.
validAgent :: Text -> Bool
validAgent a = not (T.null a) && T.length a <= 64 && T.all idChar a

-- | A key's name: the same alphabet (it names a file).
validKeyName :: Text -> Bool
validKeyName n = validAgent n && n `notElem` [".", ".."]

idChar :: Char -> Bool
idChar c = isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` ("._-" :: String)

-- | base64url without padding.
b64urlNoPad :: ByteString -> Text
b64urlNoPad = TE.decodeLatin1 . B64U.encodeUnpadded

-- | Lowercase hex.
hexText :: ByteString -> Text
hexText = T.pack . concatMap (printf "%02x") . BS.unpack

-- | A seed as it is kept (base64 of the 32 bytes; @PLEXUS_KEY@'s form).
encodeSeedText :: Seed -> Text
encodeSeedText (Seed b) = TE.decodeLatin1 (B64.encode b)

-- | A kept seed back: standard or url base64, padded or not, surrounding
-- whitespace ignored.
decodeSeedText :: Text -> Either Text Seed
decodeSeedText t =
  let s = TE.encodeUtf8 (T.strip t)
      std = B64.decode s
      url = B64U.decodeUnpadded s
      urlp = B64U.decode s
  in case [b | Right b <- [std, urlp, url]] of
       (b : _) -> seedFromBytes b
       [] -> Left "not base64"