salmon-ops-recipes-0.1.0.0: test/Test/FollowSignatureSpec.hs
{-# LANGUAGE DeriveGeneric #-}
{- | Signed documents (the "Signed documents" section of @specs/pull-mode.md@):
"Salmon.Actions.Follow.Signature" at Layer 0 — the envelope, its
canonicalisation, and every refusal with its reason — and at Layer 1 the
verifier in a following loop over a directory registry with a cache: a
signed document is applied, a tampered one is refused and never cached, and
the cache written by the good one replays through the verifier, so that
swapping the host's key refuses it too.
Keys are generated here, in the test, and never touch the working tree.
-}
module Test.FollowSignatureSpec (tests) where
import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.MVar (newEmptyMVar, putMVar)
import Control.Monad (forM_)
import Control.Concurrent.STM (TChan, atomically, newTChanIO, readTChan, writeTChan)
import Control.Exception (SomeException, throwIO, try)
import Data.Aeson (FromJSON, ToJSON, Value (..), eitherDecode, encode)
import qualified Data.Aeson.Key as Key
import qualified Data.Aeson.KeyMap as KeyMap
import Data.ByteString.Lazy (ByteString)
import qualified Data.ByteString.Lazy as LByteString
import Data.List (sortOn)
import qualified Data.Map.Strict as Map
import Data.Ord (Down (..))
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import Data.Foldable (toList)
import GHC.Generics (Generic)
import System.Directory (createDirectoryIfMissing, doesFileExist, renameDirectory)
import System.FilePath ((</>))
import System.Timeout (timeout)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
import qualified Salmon.Actions.Follow as Follow
import Salmon.Actions.Follow (Document (..), Entry (..), Label)
import qualified Salmon.Actions.Follow.Scheduler as Scheduler
import qualified Salmon.Actions.Follow.Signature as Signature
import qualified Salmon.Actions.Serve as Serve
import Salmon.Actions.Serve (Convergence (..), Direction (..), Line (..), NodeState (..), Origin (..), Producer (..), World (..))
import qualified Salmon.Actions.UpDown as UpDown
import Salmon.Builtin.Extension (Extension, Op, Track', deps, op, ref)
import qualified Salmon.Builtin.Nodes.Filesystem as FS
import Salmon.Op.Configure (Configure (..))
import Salmon.Op.Ref (mkRef)
import Salmon.Op.Track (Track (..))
import Salmon.Reporter (Reporter)
import Test.Harness (capture, withTempDir)
tests :: TestTree
tests =
testGroup
"Salmon.Actions.Follow.Signature"
[ testGroup
"Layer 0: the envelope"
[ testCase "sign then verify: the inner document comes back, canonical, and parses as the document signed" roundTrip
, testCase "a document altered inside its envelope is refused, and the reason says so" tampered
, testCase "a document signed by a key the host does not hold is refused, naming the unknown key" unknownKey
, testCase "one of several keys suffices, whichever order they are given in" severalKeys
, testCase "an unsigned document under a key is refused as unsigned" unsignedUnderKey
, testCase "an envelope re-serialised with other key order and whitespace still verifies, to the same bytes" reserialised
, testCase "no signatures, an envelope that does not parse, `none`, and a key file that is not one: each refused with its reason" refusals
, testCase "the key id is the public key's SHA-256 thumbprint, the same from either half of the pair" keyIds
]
, testGroup
"Layer 0: a signature is worth something only at the address it was signed for"
[ testCase "a document signed for canary is refused at prod, naming both labels; at canary it is accepted" labelMismatch
, testCase "a key bound to canary does not verify a prod document, and the reason says the key may not speak there" keyBoundToLabel
, testCase "a bare key speaks for any label; a key bound to two labels speaks for both" bareAndMultiKeys
, testCase "a signed document naming no label is refused by default, and accepted under the migration policy" legacyUnlabelled
, testCase "the label is inside what is signed: rewriting it in the envelope breaks the signature" labelIsSigned
, testCase "signing refuses a document that already names another label, and keeps the same one" signRefusesOtherLabel
, testCase "--follow-key arguments: FILE, LABEL=FILE, and a path containing an = stays a path" keySpecs
]
, testCase "Layer 1: a document validly signed for canary and planted at prod's address is refused, at fetch and on cache replay, and never applied" plantedAtOtherLabel
, testCase "Layer 1: a signed document is applied through a directory registry; a tampered one is refused and never cached; the cache replays through the verifier, and a swapped key refuses it" followingSigned
]
-------------------------------------------------------------------------------
-- Layer 0
-- | A document naming these seeds, as bytes — a publisher's own spelling
-- (pretty-ish, keys in the order a human writes them), not aeson's.
document :: Text -> [[String]] -> ByteString
document did seeds =
LByteString.fromStrict . Text.encodeUtf8 $
"{ \"salmon\": 1,\n \"id\": " <> quote did <> ",\n \"seeds\": [" <> Text.intercalate ", " (fmap seed seeds) <> "],\n \"note\": \"a publisher's annotation\" }\n"
where
seed ws = "{\"seed\": [" <> Text.intercalate ", " (fmap (quote . Text.pack) ws) <> "]}"
quote t = Text.decodeUtf8 (LByteString.toStrict (encode (String t)))
sign :: Signature.PrivateKey -> ByteString -> IO ByteString
sign key bytes = do
signed <- Signature.signDocument key bytes
either (assertFailure . ("signing failed: " <>) . Text.unpack) pure signed
-- | Signed for a label: the document names it, inside what is signed.
signFor :: Signature.PrivateKey -> Text -> ByteString -> IO ByteString
signFor key lbl bytes = do
signed <- Signature.signDocumentFor key (Just (label lbl)) bytes
either (assertFailure . ("signing failed: " <>) . Text.unpack) pure signed
-- | Every key speaks for every label; unlabelled documents refused.
anyLabel :: [Signature.PublicKey] -> [Signature.TrustedKey]
anyLabel = fmap Signature.trustsAnyLabel
-- | Rewrite the @document@ member of an envelope, keeping everything else.
withDocument :: (Value -> Value) -> ByteString -> ByteString
withDocument f envelope = case eitherDecode envelope of
Right (Object o) -> encode (Object (KeyMap.mapWithKey (\k v -> if k == "document" then f v else v) o))
_ -> error "not an envelope"
-- | Give the document another id: a content change a signature must catch.
retitle :: Text -> Value -> Value
retitle did (Object o) = Object (KeyMap.insert "id" (String did) o)
retitle _ v = v
parsed :: ByteString -> Document
parsed bytes = either (error . ("document does not parse: " <>)) id (eitherDecode bytes)
expectLeft :: String -> Text -> Either Text a -> IO ()
expectLeft what needle verdict = case verdict of
Right _ -> assertFailure (what <> ": accepted, expected a refusal mentioning " <> show needle)
Left why -> assertBool (what <> ": the reason " <> show why <> " does not mention " <> show needle) (needle `Text.isInfixOf` why)
roundTrip :: IO ()
roundTrip = do
key <- Signature.generateKeyPair
let pub = Signature.publicKey key
original = document "web@1" [["a"], ["b", "--flag"]]
envelope <- sign key original
-- the envelope is what a host fetches; it is not the document
assertBool "the envelope is not the document" (envelope /= original)
case Signature.verifyEnvelope [pub] envelope of
Left why -> assertFailure ("refused: " <> Text.unpack why)
Right inner -> do
assertEqual "the inner document is the canonical form of what was signed" (encodeCanonical original) inner
assertEqual "and parses as the document" (parsed original) (parsed inner)
assertEqual "seeds intact" [SeedWords ["a"], SeedWords ["b", "--flag"]] (parsed inner).docSeeds
where
encodeCanonical bytes = case eitherDecode bytes :: Either String Value of
Right v -> Signature.canonicalBytes v
Left err -> error err
tampered :: IO ()
tampered = do
key <- Signature.generateKeyPair
envelope <- sign key (document "web@1" [["a"]])
let evil = withDocument (retitle "web@evil") envelope
-- the tampered envelope still parses as an envelope, so the refusal is
-- the signature's, not the parser's
expectLeft "tampered" "does not verify" (Signature.verifyEnvelope [Signature.publicKey key] evil)
expectLeft "tampered" "altered after signing" (Signature.verifyEnvelope [Signature.publicKey key] evil)
unknownKey :: IO ()
unknownKey = do
signer <- Signature.generateKeyPair
other <- Signature.generateKeyPair
envelope <- sign signer (document "web@1" [["a"]])
let verdict = Signature.verifyEnvelope [Signature.publicKey other] envelope
expectLeft "unknown key" "names no configured key" verdict
expectLeft "unknown key" (Text.take 12 (Signature.keyId (Signature.publicKey signer))) verdict
expectLeft "unknown key" "1 configured key" verdict
severalKeys :: IO ()
severalKeys = do
k1 <- Signature.generateKeyPair
k2 <- Signature.generateKeyPair
k3 <- Signature.generateKeyPair
envelope <- sign k2 (document "web@1" [["a"]])
let pubs = fmap Signature.publicKey [k1, k2, k3]
assertBool "k2 among three accepts" (either (const False) (const True) (Signature.verifyEnvelope pubs envelope))
assertBool "in any order" (either (const False) (const True) (Signature.verifyEnvelope (reverse pubs) envelope))
expectLeft "without k2" "2 configured key" (Signature.verifyEnvelope (fmap Signature.publicKey [k1, k3]) envelope)
unsignedUnderKey :: IO ()
unsignedUnderKey = do
key <- Signature.generateKeyPair
let verdict = Signature.verifyEnvelope [Signature.publicKey key] (document "web@1" [["a"]])
expectLeft "unsigned" "unsigned document" verdict
expectLeft "unsigned" "--follow-key" verdict
reserialised :: IO ()
reserialised = do
key <- Signature.generateKeyPair
let pub = Signature.publicKey key
envelope <- sign key (document "web@1" [["a", "--n", "1"], ["b"]])
let other = rerender envelope
assertBool "the rendering differs" (other /= envelope)
assertBool "and is still JSON with the same content" (eitherDecode other == (eitherDecode envelope :: Either String Value))
case (Signature.verifyEnvelope [pub] envelope, Signature.verifyEnvelope [pub] other) of
(Right a, Right b) -> assertEqual "both verify to the same inner bytes" a b
(a, b) -> assertFailure ("expected both to verify: " <> show (a, b))
{- | The same JSON value with every object's keys in /descending/ order,
spaces everywhere aeson puts none, and a trailing newline: what a
pretty-printer, a proxy or a registry written in another language might
turn an envelope into. -}
rerender :: ByteString -> ByteString
rerender bytes = case eitherDecode bytes of
Left err -> error err
Right v -> LByteString.fromStrict (Text.encodeUtf8 (go v)) <> "\n"
where
go :: Value -> Text
go (Object o) =
"{ " <> Text.intercalate " , " [quoteKey k <> " : " <> go x | (k, x) <- sortOn (Down . fst) (KeyMap.toList o)] <> " }"
go (Array xs) = "[ " <> Text.intercalate " , " (fmap go (toList xs)) <> " ]"
go scalar = Text.decodeUtf8 (LByteString.toStrict (encode scalar))
quoteKey k = go (String (Key.toText k))
refusals :: IO ()
refusals = withTempDir $ \dir -> do
key <- Signature.generateKeyPair
let pub = Signature.publicKey key
envelopeWith :: Text -> ByteString
envelopeWith sigs = LByteString.fromStrict (Text.encodeUtf8 ("{\"salmon-signed\": 1, \"document\": {}, \"signatures\": " <> sigs <> "}"))
expectLeft "no signatures" "carries no signatures" (Signature.verifyEnvelope [pub] (envelopeWith "[]"))
expectLeft "signatures not a list" "does not parse" (Signature.verifyEnvelope [pub] (envelopeWith "\"nope\""))
expectLeft "a signature without its key" "does not parse" (Signature.verifyEnvelope [pub] (envelopeWith "[{\"alg\": \"EdDSA\", \"sig\": \"\"}]"))
expectLeft "not JSON" "not even JSON" (Signature.verifyEnvelope [pub] "{{{")
expectLeft "another version" "does not parse" (Signature.verifyEnvelope [pub] "{\"salmon-signed\": 2, \"document\": {}, \"signatures\": []}")
-- `none` with an empty signature is what jose's own `verify` would
-- accept; the verifier must not hand it that
let none = envelopeWith ("[{\"key\": \"" <> Signature.keyId pub <> "\", \"alg\": \"none\", \"sig\": \"\"}]")
expectLeft "alg none" "no public key can verify" (Signature.verifyEnvelope [pub] none)
-- an HMAC named by the key id: same refusal, a public key has no secret
let hmac = envelopeWith ("[{\"key\": \"" <> Signature.keyId pub <> "\", \"alg\": \"HS256\", \"sig\": \"AAAA\"}]")
expectLeft "alg HS256" "no public key can verify" (Signature.verifyEnvelope [pub] hmac)
-- no key at all refuses rather than accepts
envelope <- sign key (document "web@1" [["a"]])
expectLeft "no keys" "no signing key" (Signature.verifyEnvelope [] envelope)
-- key files
LByteString.writeFile (dir </> "garbage") "not a key\n"
badFile <- Signature.readPublicKeyFile (dir </> "garbage")
expectLeft "not a JWK" "not a JWK" (() <$ badFile)
missing <- Signature.readPublicKeyFile (dir </> "absent")
expectLeft "missing file" "absent" (() <$ missing)
Signature.writeKeyPair (dir </> "k") key
onlyPublic <- Signature.readPrivateKeyFile (dir </> "k.pub")
expectLeft "the public half cannot sign" "no private material" (() <$ onlyPublic)
keyIds :: IO ()
keyIds = withTempDir $ \dir -> do
key <- Signature.generateKeyPair
Signature.writeKeyPair (dir </> "k") key
fromPrivate <- Signature.readPublicKeyFile (dir </> "k")
fromPublic <- Signature.readPublicKeyFile (dir </> "k.pub")
case (fromPrivate, fromPublic) of
(Right a, Right b) -> do
assertEqual "the same public key from either file" a b
assertEqual "the same id" (Signature.keyId a) (Signature.keyId (Signature.publicKey key))
assertEqual "64 hex characters" 64 (Text.length (Signature.keyId a))
assertBool "hex" (Text.all (`elem` ("0123456789abcdef" :: String)) (Signature.keyId a))
other -> assertFailure ("could not read the pair back: " <> show other)
another <- Signature.generateKeyPair
assertBool "two keys, two ids" (Signature.keyId (Signature.publicKey another) /= Signature.keyId (Signature.publicKey key))
-------------------------------------------------------------------------------
-- Layer 1: the served thing and the loop, same harness as Test.FollowRegistrySpec
data Spec = Spec
{ specDir :: FilePath
, specNames :: [String]
}
deriving (Eq, Show, Generic)
instance ToJSON Spec
instance FromJSON Spec
parseSpec :: FilePath -> [String] -> Either Text Spec
parseSpec root args
| null args = Left "expected at least one file name"
| otherwise = Right (Spec (root </> "files") args)
program :: Track' Spec
program = Track $ \spec ->
op "follow-signature-root" (deps (fmap (fileOp spec.specDir) spec.specNames)) $ \actions ->
actions{ref = mkRef "follow-signature-root" (spec.specDir, spec.specNames)}
fileOp :: FilePath -> String -> Op
fileOp d n = FS.filecontents (FS.FileContents (d </> n) ("contents of " <> n))
data Driver = Driver
{ typeLine :: String -> IO ()
, serveReports :: IO [Serve.Report]
, followReports :: IO [Follow.Report]
}
interval :: Int
interval = 100000
schedule :: Scheduler.Config
schedule =
Scheduler.Config
{ Scheduler.schedBase = interval
, Scheduler.schedFactor = 2
, Scheduler.schedCap = 4 * interval
, Scheduler.schedJitter = 0
, Scheduler.schedDebounce = 0
, Scheduler.schedMaxWait = 0
}
withFollowing :: FilePath -> FilePath -> Follow.Verifier -> [Label] -> (Driver -> IO a) -> IO (World Spec Spec, [Serve.Report], [Follow.Report], a)
withFollowing root reg verifier labels body = do
(serveReporter, readServe) <- capture
(followReporter, readFollow) <- capture
(nodeReporter, _) <- capture :: IO (Reporter (UpDown.Report Extension), IO [UpDown.Report Extension])
stdinChan <- newTChanIO
gate <- newEmptyMVar
pk <- Scheduler.newPoke
modeVar <- Follow.newMode
appliedVar <- Follow.newApplied
let follow =
Follow.Follow
{ Follow.followRegistry = Follow.directoryRegistry reg
, Follow.followLabels = labels
, Follow.followSchedule = schedule
, Follow.followCache = Just (root </> "cache")
, Follow.followRefuseOlder = False
, Follow.followVerify = verifier
}
producers =
[ Follow.follower followReporter pk modeVar appliedVar follow (putMVar gate ())
, Follow.gated gate (chanProducer stdinChan)
]
driver =
Driver
{ typeLine = \l -> atomically (writeTChan stdinChan (Just l))
, serveReports = readServe
, followReports = readFollow
}
resultVar <- newTChanIO
_ <- forkIO $ do
outcome <- try (body driver)
atomically (writeTChan stdinChan Nothing)
atomically (writeTChan resultVar outcome)
w <- Serve.serveFollowing [] Nothing True serveReporter nodeReporter (parseSpec root) (Configure pure) program (Just (Follow.followed pk modeVar appliedVar)) producers
outcome <- atomically (readTChan resultVar)
case outcome of
Left (ex :: SomeException) -> throwIO ex
Right a -> (,,,) w <$> readServe <*> readFollow <*> pure a
chanProducer :: TChan (Maybe String) -> Producer
chanProducer ch = Producer go
where
go inbox = do
next <- atomically (readTChan ch)
case next of
Nothing -> atomically (writeTChan inbox (Eof Stdin))
Just l -> atomically (writeTChan inbox (Line Stdin l)) >> go inbox
label :: Text -> Label
label t = either (error . Text.unpack) id (Follow.mkLabel t)
waitFor :: String -> IO Bool -> IO ()
waitFor what cond = do
ok <- timeout (10 * 1000000) go
case ok of
Just () -> pure ()
Nothing -> assertFailure ("timed out waiting for " <> what)
where
go = do
done <- cond
if done then pure () else threadDelay 20000 >> go
fileExists :: FilePath -> String -> IO Bool
fileExists root n = doesFileExist (root </> "files" </> n)
injections :: [Follow.Report] -> [(Int, Int)]
injections reports = [(nup, ndown) | Follow.Injected _ _ _ nup ndown <- reports]
rejections :: [Follow.Report] -> [(Follow.Digest, Text)]
rejections reports = [(dg, why) | Follow.Rejected _ dg why <- reports]
allConvergedUp :: World seed directive -> Bool
allConvergedUp w = not (Map.null w.worldNodes) && all (\st -> st.nodeDirection == TurnUp && st.nodeConvergence == Converged) (Map.elems w.worldNodes)
followingSigned :: IO ()
followingSigned =
withTempDir $ \root -> do
key <- Signature.generateKeyPair
other <- Signature.generateKeyPair
let web = label "web"
reg = root </> "reg"
cache = root </> "cache"
verifier = Signature.signedVerifier Signature.RefuseUnlabelled (anyLabel [Signature.publicKey key])
publish bytes = createDirectoryIfMissing True reg >> LByteString.writeFile (Follow.documentPath reg web) bytes
good <- signFor key "web" (document "web@1" [["a"]])
let evil = withDocument (retitle "web@evil") good
publish good
(w, _, freports, good2) <- withFollowing root reg verifier [web] $ \d -> do
waitFor "the file" (fileExists root "a")
-- the tampered envelope: refused, never applied, never cached
publish evil
waitFor "the refusal" (not . null . rejections <$> d.followReports)
threadDelay (2 * interval)
declared <- (\rs -> [() | Serve.Declared{} <- rs]) <$> d.serveReports
assertEqual "one declaration, the signed document's" 1 (length declared)
cached <- Follow.readCacheEntry cache web
assertEqual "the cache still holds the signed document, envelope and all" (Right (Just ("web@1", Follow.digestOf good, good))) (fmap (fmap (\c -> (c.cachedId, c.cachedDigest, c.cachedBytes))) cached)
-- and a good document again is applied on top
good2 <- signFor key "web" (document "web@2" [["a"], ["b"]])
publish good2
waitFor "the next file" (fileExists root "b")
pure good2
case rejections freports of
[(dg, why)] -> do
assertBool ("the one refusal is the signature's: " <> Text.unpack why) ("does not verify" `Text.isInfixOf` why)
assertEqual "and it names the envelope's digest, the bytes as fetched" (Follow.digestOf evil) dg
rs -> assertFailure ("expected exactly one refusal, got " <> show rs)
assertEqual "two injections" [(1, 0), (1, 0)] (injections freports)
assertBool "the world converged" (allConvergedUp w)
-- what the fetcher records is the document's id, from inside the
-- envelope
assertEqual "the injections name the documents' ids" ["web@1", "web@2"] [did | Follow.Injected _ did _ _ _ <- freports]
-- the registry goes away: the cache replays through the same key
renameDirectory reg (reg <> ".away")
renameDirectory (root </> "files") (root </> "files.away")
(w2, _, freports2, ()) <- withFollowing root reg verifier [web] $ \_ ->
waitFor "the files, rebuilt from the cache" ((&&) <$> fileExists root "a" <*> fileExists root "b")
assertEqual "replayed, once" ["web@2"] [did | Follow.Replayed _ did _ <- freports2]
assertEqual "nothing refused" [] (rejections freports2)
assertBool "the replayed world converged" (allConvergedUp w2)
-- the host's key is swapped: the same cache entry is refused on replay
renameDirectory (root </> "files") (root </> "files.away2")
(_, sreports3, freports3, ()) <- withFollowing root reg (Signature.signedVerifier Signature.RefuseUnlabelled (anyLabel [Signature.publicKey other])) [web] $ \d -> do
waitFor "the refusal" (not . null . rejections <$> d.followReports)
threadDelay (2 * interval)
assertEqual "nothing replayed" [] [() | Follow.Replayed{} <- freports3]
assertEqual "nothing declared" [] [() | Serve.Declared{} <- sreports3]
present <- fileExists root "a"
assertBool "nothing rebuilt" (not present)
case rejections freports3 of
[(dg, why)] -> do
assertBool ("the refusal names the unknown key: " <> Text.unpack why) ("names no configured key" `Text.isInfixOf` why)
assertEqual "the refusal names the cache entry's digest, the last good envelope's" (Follow.digestOf good2) dg
rs -> assertFailure ("expected exactly one refusal, got " <> show rs)
-------------------------------------------------------------------------------
-- labels
verdictFor :: Signature.Legacy -> [Signature.TrustedKey] -> Text -> ByteString -> Either Text ByteString
verdictFor legacy keys lbl = Signature.verifyEnvelopeFor legacy keys (label lbl)
labelMismatch :: IO ()
labelMismatch = do
key <- Signature.generateKeyPair
let keys = anyLabel [Signature.publicKey key]
canary <- signFor key "canary" (document "web@1" [["a"]])
assertBool "accepted at the label it was signed for" (either (const False) (const True) (verdictFor Signature.RefuseUnlabelled keys "canary" canary))
case verdictFor Signature.RefuseUnlabelled keys "prod" canary of
Right _ -> assertFailure "a canary document was accepted at prod"
Left why -> do
assertBool ("names the label it was signed for: " <> Text.unpack why) ("signed for label canary" `Text.isInfixOf` why)
assertBool ("names the label it was fetched for: " <> Text.unpack why) ("fetched for label prod" `Text.isInfixOf` why)
keyBoundToLabel :: IO ()
keyBoundToLabel = do
canaryKey <- Signature.generateKeyPair
prodKey <- Signature.generateKeyPair
let keys =
[ Signature.trustsOnly (label "canary") (Signature.publicKey canaryKey)
, Signature.trustsOnly (label "prod") (Signature.publicKey prodKey)
]
fromCanaryKey <- signFor canaryKey "prod" (document "web@1" [["a"]])
-- the document even names prod, but the key that signed it may not speak there
expectLeft "canary's key at prod" "may not speak for label prod" (verdictFor Signature.RefuseUnlabelled keys "prod" fromCanaryKey)
fromProdKey <- signFor prodKey "prod" (document "web@1" [["a"]])
assertBool "prod's key at prod" (either (const False) (const True) (verdictFor Signature.RefuseUnlabelled keys "prod" fromProdKey))
expectLeft "a label no key speaks for" "no signing key" (verdictFor Signature.RefuseUnlabelled keys "staging" fromProdKey)
bareAndMultiKeys :: IO ()
bareAndMultiKeys = do
bare <- Signature.generateKeyPair
two <- Signature.generateKeyPair
let keys =
[ Signature.trustsAnyLabel (Signature.publicKey bare)
, Signature.TrustedKey (Signature.publicKey two) (Just [label "a", label "b"])
]
forM_ ["a", "b", "c"] $ \l -> do
d <- signFor bare l (document "web@1" [["x"]])
assertBool ("bare key at " <> Text.unpack l) (either (const False) (const True) (verdictFor Signature.RefuseUnlabelled keys l d))
forM_ ["a", "b"] $ \l -> do
d <- signFor two l (document "web@1" [["x"]])
assertBool ("two-label key at " <> Text.unpack l) (either (const False) (const True) (verdictFor Signature.RefuseUnlabelled keys l d))
dc <- signFor two "c" (document "web@1" [["x"]])
expectLeft "two-label key at c" "may not speak for label c" (verdictFor Signature.RefuseUnlabelled keys "c" dc)
legacyUnlabelled :: IO ()
legacyUnlabelled = do
key <- Signature.generateKeyPair
let keys = anyLabel [Signature.publicKey key]
old <- sign key (document "web@1" [["a"]])
expectLeft "unlabelled, by default" "names no label" (verdictFor Signature.RefuseUnlabelled keys "prod" old)
expectLeft "and the reason says how to migrate" "--follow-accept-unlabelled" (verdictFor Signature.RefuseUnlabelled keys "prod" old)
assertBool "accepted under the migration policy" (either (const False) (const True) (verdictFor Signature.AcceptUnlabelled keys "prod" old))
-- the flag does not weaken a document that does name a label
canary <- signFor key "canary" (document "web@1" [["a"]])
expectLeft "a mismatch under the migration policy" "signed for label canary" (verdictFor Signature.AcceptUnlabelled keys "prod" canary)
labelIsSigned :: IO ()
labelIsSigned = do
key <- Signature.generateKeyPair
let keys = anyLabel [Signature.publicKey key]
canary <- signFor key "canary" (document "web@1" [["a"]])
-- what a registry that can write but not sign would try: relabel the document
let relabelled = withDocument (\v -> case v of Object o -> Object (KeyMap.insert "label" (String "prod") o); other -> other) canary
expectLeft "relabelled in the envelope" "does not verify" (verdictFor Signature.RefuseUnlabelled keys "prod" relabelled)
signRefusesOtherLabel :: IO ()
signRefusesOtherLabel = do
key <- Signature.generateKeyPair
canary <- signFor key "canary" (document "web@1" [["a"]])
-- sign the labelled document's own bytes again for another label
let inner = case eitherDecode canary of
Right (Object o) | Just d <- KeyMap.lookup "document" o -> encode d
_ -> error "not an envelope"
refused <- Signature.signDocumentFor key (Just (label "prod")) inner
case refused of
Left why -> assertBool ("names both: " <> Text.unpack why) ("prod" `Text.isInfixOf` why)
Right _ -> assertFailure "re-signed a canary document for prod"
same <- Signature.signDocumentFor key (Just (label "canary")) inner
assertBool "the same label is fine" (either (const False) (const True) same)
keySpecs :: IO ()
keySpecs = do
assertEqual "bare" (Right (Nothing, "keys/a.pub")) (Signature.parseKeySpec "keys/a.pub")
assertEqual "labelled" (Right (Just (label "canary"), "keys/a.pub")) (Signature.parseKeySpec "canary=keys/a.pub")
assertEqual "a path containing = stays a path" (Right (Nothing, "keys/a=b.pub")) (Signature.parseKeySpec "keys/a=b.pub")
assertBool "a bad label is refused" (either (const True) (const False) (Signature.parseKeySpec "bad label=keys/a.pub"))
-- | The attack, end to end: a document validly signed for `canary` is copied
-- to `prod`'s address. Refused as fetched, and refused again when it sits in
-- the cache and is replayed; nothing is declared and nothing is built.
plantedAtOtherLabel :: IO ()
plantedAtOtherLabel =
withTempDir $ \root -> do
key <- Signature.generateKeyPair
let prod = label "prod"
reg = root </> "reg"
cache = root </> "cache"
verifier = Signature.signedVerifier Signature.RefuseUnlabelled (anyLabel [Signature.publicKey key])
publish bytes = createDirectoryIfMissing True reg >> LByteString.writeFile (Follow.documentPath reg prod) bytes
forCanary <- signFor key "canary" (document "canary@1" [["a"]])
publish forCanary
(_, sreports, freports, ()) <- withFollowing root reg verifier [prod] $ \d -> do
waitFor "the refusal" (not . null . rejections <$> d.followReports)
threadDelay (2 * interval)
assertEqual "nothing declared" [] [() | Serve.Declared{} <- sreports]
present <- fileExists root "a"
assertBool "nothing built" (not present)
case rejections freports of
[(dg, why)] -> do
assertEqual "it names the envelope's digest" (Follow.digestOf forCanary) dg
assertBool ("naming both labels: " <> Text.unpack why) ("signed for label canary" `Text.isInfixOf` why && "fetched for label prod" `Text.isInfixOf` why)
rs -> assertFailure ("expected exactly one refusal, got " <> show rs)
-- the same bytes as a cache entry for prod, read back through the same verifier
forProd <- signFor key "prod" (document "prod@1" [["b"]])
publish forProd
(_, _, freports2, ()) <- withFollowing root reg verifier [prod] $ \_ ->
waitFor "the file" (fileExists root "b")
assertEqual "the properly labelled document is applied" ["prod@1"] [did | Follow.Injected _ did _ _ _ <- freports2]
-- now plant the canary envelope as prod's cache entry, with the registry gone
renameDirectory reg (reg <> ".away")
renameDirectory (root </> "files") (root </> "files.away")
Follow.writeCache cache prod (Follow.Cached "canary@1" (Follow.digestOf forCanary) forCanary)
(_, sreports3, freports3, ()) <- withFollowing root reg verifier [prod] $ \d -> do
waitFor "the refusal on replay" (not . null . rejections <$> d.followReports)
threadDelay (2 * interval)
assertEqual "nothing replayed" [] [() | Follow.Replayed{} <- freports3]
assertEqual "nothing declared on replay" [] [() | Serve.Declared{} <- sreports3]
present2 <- fileExists root "b"
assertBool "nothing rebuilt from the planted cache entry" (not present2)