packages feed

salmon-ops-0.1.0.0: src/Salmon/Builtin/Nodes/Keys.hs

module Salmon.Builtin.Nodes.Keys where

import Salmon.Actions.UpDown (skipIfFileExists)
import Salmon.Builtin.Extension
import Salmon.Builtin.Nodes.Binary (Binary, Command (..), withBinary)
import qualified Salmon.Builtin.Nodes.Binary as Binary
import Salmon.Builtin.Nodes.Filesystem
import Salmon.Op.Ref
import Salmon.Op.Track
import Salmon.Reporter

import Control.Monad (void)
import qualified Crypto.JOSE.JWK as JWK
import Data.Aeson (encode)
import qualified Data.ByteString.Lazy as LBS
import Data.Functor.Contravariant (contramap)
import Data.Text (Text)
import qualified Data.Text as Text

import System.FilePath ((</>))
import System.Process.ByteString (readCreateProcessWithExitCode)
import System.Process.ListLike (CreateProcess, proc)

data Report
    = MakeSshKey !SSHKeyPair Binary.Report
    | SignKeyReport !SSHKeyPair Binary.Report
    deriving (Show)

-------------------------------------------------------------------------------

data KeyType
    = RSA2048
    | RSA4096
    | ED25519
    deriving (Eq, Ord, Show)

data SSHKeyPair = SSHKeyPair {sshKeyType :: KeyType, sshKeyDir :: FilePath, sshKeyName :: Text}
    deriving (Eq, Ord, Show)

privateKeyPath :: SSHKeyPair -> FilePath
privateKeyPath key = key.sshKeyDir </> Text.unpack key.sshKeyName

publicKeyPath :: SSHKeyPair -> FilePath
publicKeyPath key = key.sshKeyDir </> Text.unpack key.sshKeyName <> ".pub"

publicCAKeyPath :: SSHKeyPair -> FilePath
publicCAKeyPath key = key.sshKeyDir </> Text.unpack key.sshKeyName <> "-cert.pub"

-------------------------------------------------------------------------------

sshKey :: Reporter Report -> Track' (Binary "ssh-keygen") -> SSHKeyPair -> Op
sshKey r bin key =
    withBinary bin sshkeygen (Keygen (key.sshKeyType, filepath)) $ \up ->
        op "ssh-key" (deps [enclosingdir]) $ \actions ->
            actions
                { help = "generate an ssh-key"
                , notes =
                    [ "keeps keys around"
                    ]
                , ref = mkRef "ssh" (sshdir, key.sshKeyName)
                , check = skipIfFileExists filepath
                , up = up r'
                }
  where
    r' :: Reporter Binary.Report
    r' = contramap (MakeSshKey key) r
    filename :: FilePath
    filename = Text.unpack key.sshKeyName

    sshdir :: FilePath
    sshdir = key.sshKeyDir

    filepath :: FilePath
    filepath = sshdir </> filename

    enclosingdir :: Op
    enclosingdir = dir (Directory sshdir)

newtype Keygen = Keygen (KeyType, FilePath)

sshkeygen :: Command "ssh-keygen" Keygen
sshkeygen = Command $ \(Keygen (kt, filepath)) ->
    case kt of
        RSA2048 -> proc "ssh-keygen" ["-t", "rsa", "-b", "2048", "-N", "", "-f", filepath]
        RSA4096 -> proc "ssh-keygen" ["-t", "rsa", "-b", "4096", "-N", "", "-f", filepath]
        ED25519 -> proc "ssh-keygen" ["-t", "ed25519", "-N", "", "-f", filepath]

newtype KeyIdentifier = KeyIdentifier {getIdentifier :: Text}
    deriving (Eq, Ord, Show)

data SSHCertificateAuthority = SSHCertificateAuthority {sshcaKey :: SSHKeyPair}
    deriving (Eq, Ord, Show)

-------------------------------------------------------------------------------

newtype Principal = Principal {getPrincipal :: Text}
    deriving (Eq, Ord, Show)

{- | @principals@ must be non-empty: modern OpenSSH (checked against 9.6p1)
rejects a certificate with an empty principal list outright at auth time
(@Certificate lacks principal list@), even with a matching
@TrustedUserCAKeys@ — it is not, as older docs/folklore suggest, "valid for
any principal" (hand-validated 2026-08-20, see
@specs/qemu-test-vms-progress.md@). Pass the login name(s) this key is
meant to authenticate as, e.g. @[Principal "root"]@.
-}
signKey ::
    Reporter Report ->
    Track' (Binary "ssh-keygen") ->
    SSHCertificateAuthority ->
    KeyIdentifier ->
    [Principal] ->
    SSHKeyPair ->
    Op
signKey r bin ca kid principals keyToSign =
    withBinary bin sshsign (SignKey (ca, kid, principals, (privateKeyPath keyToSign))) $ \up ->
        op "ssh-ca-sign" (deps preds) $ \actions ->
            actions
                { help = "sign a SSH-key"
                , ref = mkRef "ssh-ca-sign" (show ca, kid.getIdentifier)
                , check = skipIfFileExists (publicCAKeyPath keyToSign)
                , up = up r'
                }
  where
    r' = contramap (SignKeyReport keyToSign) r
    preds =
        [ sshKey r bin keyToSign
        , sshKey r bin ca.sshcaKey
        ]

newtype SignKey = SignKey (SSHCertificateAuthority, KeyIdentifier, [Principal], FilePath)

sshsign :: Command "ssh-keygen" SignKey
sshsign = Command $ \(SignKey (ca, kid, principals, certifiedPath)) ->
    proc "ssh-keygen" $
        [ "-s"
        , privateKeyPath ca.sshcaKey
        , "-I"
        , Text.unpack kid.getIdentifier
        , "-n"
        , Text.unpack (Text.intercalate "," (map getPrincipal principals))
        , certifiedPath
        ]

data JWKKeyPair = JWKKeyPair {jwkKeyType :: KeyType, jwkKeyDir :: FilePath, jwkKeyName :: Text}
    deriving (Eq, Ord, Show)

jwkfilepath :: JWKKeyPair -> FilePath
jwkfilepath key =
    key.jwkKeyDir </> Text.unpack key.jwkKeyName

jwkKey :: JWKKeyPair -> Op
jwkKey key =
    op "jwk-key" (deps [enclosingdir]) $ \actions ->
        actions
            { help = "generate a jwk-key"
            , notes = ["keeps keys around"]
            , ref = mkRef "jwk" (jwkdir, key.jwkKeyName)
            , check = skipIfFileExists (jwkfilepath key)
            , up = up
            }
  where
    up :: IO ()
    up = jwk >>= LBS.writeFile (jwkfilepath key) . encode

    jwk :: IO JWK.JWK
    jwk = case key.jwkKeyType of
        RSA2048 -> JWK.genJWK (JWK.RSAGenParam (2048 `div` 8))
        RSA4096 -> JWK.genJWK (JWK.RSAGenParam (4096 `div` 8))
        ED25519 -> JWK.genJWK (JWK.OKPGenParam JWK.Ed25519)

    filename :: FilePath
    filename = Text.unpack key.jwkKeyName

    jwkdir :: FilePath
    jwkdir = key.jwkKeyDir

    enclosingdir :: Op
    enclosingdir = dir (Directory jwkdir)