diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index e8a9a1cec75bb04c084c9ecb6acd004cfd63d99d..e4e246e22477990844330942af8397d78b650105 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -16,7 +16,7 @@ on:
 jobs:
   build:
     # The type of runner that the job will run on
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-20.04
 
     # Steps represent a sequence of tasks that will be executed as part of the job
     steps:
@@ -53,7 +53,7 @@ jobs:
         run: git update-index --refresh && git diff-index --quiet HEAD -- || git diff
   e2e:
     # The type of runner that the job will run on
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-20.04 
     needs: build
     # Steps represent a sequence of tasks that will be executed as part of the job
     steps:
diff --git a/go.mod b/go.mod
index fc59e19411ac43e27be140e8cca404a46ccfeba1..024c5141ce6be537c6f7ed090d5c367f2d556650 100644
--- a/go.mod
+++ b/go.mod
@@ -21,6 +21,7 @@ require (
 	github.com/go-openapi/validate v0.20.1
 	github.com/golang/protobuf v1.4.3
 	github.com/google/certificate-transparency-go v1.1.0 // indirect
+	github.com/google/go-cmp v0.5.2
 	github.com/google/rpmpack v0.0.0-20210107155803-d6befbf05148
 	github.com/google/trillian v1.3.10
 	github.com/jedisct1/go-minisign v0.0.0-20210106175330-e54e81d562c7
diff --git a/go.sum b/go.sum
index bb78ceaa6499266a6d7531a1682b11f23b424393..076b6f717e76cf707e995f32678b74dcbc45b46a 100644
--- a/go.sum
+++ b/go.sum
@@ -979,6 +979,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78 h1:nVuTkr9L6Bq62qpUqKo/RnZCFfzDBL0bYo6w9OJUqZY=
 golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.0.0-20170915090833-1cbadb444a80/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/pkg/pki/ssh/README.md b/pkg/pki/ssh/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..cad38ac541af7414e008bdd4c0acb2fb1e6de561
--- /dev/null
+++ b/pkg/pki/ssh/README.md
@@ -0,0 +1,118 @@
+# SSH File Signatures
+
+SSH keys can be used to sign files!
+Unfortunately this is a pretty recent change to the openssh tooling, so it is not
+supported by golang.org/x/crypto/ssh yet.
+
+This document explains how it works at a high level.
+
+## Keys
+
+SSH keys are usually split into public and private files, named `id_rsa.pub` and
+`id_rsa`, respectively.
+These files are encoded and formatted a little differently than other signing keys.
+
+### Public Keys
+
+These are typically in the "known hosts" format.
+This looks something like:
+
+```
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDw0ZWP4zZLELSJVenQTQsrFJVBnoP64KTg/UWRU6qOb8HEOdtHJDOyTmo9dvN/yJoTFtWAfQEjaTsMVJzTD0gOk6ncTsp0BUtgXawSCfEUiv7v+2VgSVbUfAv/NL+HEGSCdcORnansIyrZaHwAjR3ei3O+pRWvgjRj3pOH1rWGrxaC5IbsELYzS/HvwAG/uwcxgBv4POvaq6eCEHVbqRjIYjjoYsC+c24sgSQxOyXvDS7j2z9TPHPvepDhVr9y6xnnqhLqZEWmidRrbb35aYkVLJxmGTFy/JW1cewyU2Jb3+sKQOiOwL7DAB39tRyec2ed+EHh6QLW4pcMnoXsWuPyi+G595HiUYmIlqXJ5JPo0Cv/rOJrmWSFceWiDjC/SeODp/AcK0EsN/p3wOp6ac7EzAz9Npri0vwSQX4MUYlya/olKiKCx5GIhTZtXioREPd8v4osx2VrVyDxKX99PVVbxw1FXSe4u+PuOawJzUA4vW41mxUY9zoAsb/fvoNPtrrT9HfC+7Pg6ryBdz+445M8Atc8YjjLeYXkTXWD6KMielRzBFFoIwIgi0bMotq3iQ9IwjQSXPMDQLb+UPg8xqsgRsX3wvyZzdBhxO4Bdomv7JYmySysaGgliHktU8qRse1lpDIXMovPtowywcKL4U3seDKrq7saVO0qdsLavy1o0w== lorenc.d@gmail.com
+```
+
+These can be parsed with [ParseKnownHosts](https://pkg.go.dev/golang.org/x/crypto/ssh#ParseKnownHosts)
+, NOT `ParsePublicKey`.
+
+In addition to the key material itself, this can contain the algorithm (`ssh-rsa` here) and a comment
+(lorenc.d@gmail.com) here.
+
+### Private Keys
+
+These are stored in an "armored" PEM format, resembling PGP or x509 keys:
+
+```
+-----BEGIN SSH PRIVATE KEY-----
+<base64 encoded key here>
+-----END SSH PRIVATE KEY-----
+```
+
+These can be parsed correctly with [ParsePrivateKey](https://pkg.go.dev/golang.org/x/crypto/ssh#ParsePrivateKey).
+
+## Wire Format
+
+The wire format is relatively standard.
+
+* Bytes are laid out in order.
+* Fixed-length fields are laid out at the proper offset with the specified length.
+* Strings are stored with the size as a prefix.
+
+## Signature
+
+These can be generated and validated from the command line with the `ssh-keygen -Y` set of commands:
+`sign`, `verify`, and `check-novalidate`.
+
+To work with them in Go is a little tricker.
+The signature is stored using a struct packed using the `openssh` wire format.
+The data that is used in the signing function is also packed in another struct before it is signed.
+
+### Signature Format
+
+Signatures are formatted on disk in a PEM-encoded format.
+The header is `-----BEGIN SSH SIGNATURE-----`, and the end is `-----BEGIN SSH SIGNATURE-----`.
+The signature contents are base64-encoded.
+
+The signature contents are wrapped with extra metadata, then encoded as a struct using the
+`openssh` wire format.
+That struct is defined [here](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig#L34).
+
+In Go:
+
+```
+type WrappedSig struct {
+	MagicHeader   [6]byte
+	Version       uint32
+	PublicKey     string
+	Namespace     string
+	Reserved      string
+	HashAlgorithm string
+	Signature     string
+}
+```
+
+The `PublicKey` and `Signature` fields are also stored as openssh-wire-formatted structs.
+The `MagicHeader` is `SSHSIG`.
+The `Version` is 1.
+The `Namespace` is `file` (for this use-case).
+`Reserved` must be empty.
+
+Go can already parse the `PublicKey` and `Signature` fields,
+and the `Signature` struct contains a `Blob` with the signature data.
+
+### Signed Message
+
+In addition to these wrappers, the message to be signed is wrapped with some metadata before
+it is passed to the signing function.
+
+That wrapper is defined [here](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig#L81).
+
+And in Go:
+
+```
+type MessageWrapper struct {
+	Namespace     string
+	Reserved      string
+	HashAlgorithm string
+	Hash          string
+}
+```.
+
+So, the data must first be hashed, then packed in this struct and encoded in the
+openssh wire format.
+Then, this resulting data is signed using the desired signature function.
+
+The `Namespace` field must be `file` (for this usecase).
+The `Reserved` field must be empty.
+
+The output of this signature function (and the hash) becomes the `Signature.Blob`
+value, which gets wire-encoded, wrapped, wire-encoded and finally pem-encoded.
diff --git a/pkg/pki/ssh/encode.go b/pkg/pki/ssh/encode.go
new file mode 100644
index 0000000000000000000000000000000000000000..b9bdea43c8aa78708a6c5ce960de7a358008aa82
--- /dev/null
+++ b/pkg/pki/ssh/encode.go
@@ -0,0 +1,73 @@
+package ssh
+
+import (
+	"encoding/pem"
+	"errors"
+	"fmt"
+
+	"golang.org/x/crypto/ssh"
+)
+
+const (
+	namespace = "file"
+	pemType   = "SSH SIGNATURE"
+)
+
+func Armor(s *ssh.Signature, p ssh.PublicKey) string {
+	sig := WrappedSig{
+		Version:       1,
+		PublicKey:     string(p.Marshal()),
+		Namespace:     namespace,
+		HashAlgorithm: hashAlgorithm,
+		Signature:     string(ssh.Marshal(s)),
+	}
+	copy(sig.MagicHeader[:], []byte(magicHeader))
+
+	enc := pem.EncodeToMemory(&pem.Block{
+		Type:  pemType,
+		Bytes: ssh.Marshal(sig),
+	})
+	return string(enc)
+}
+
+func Decode(s string) (*ssh.Signature, ssh.PublicKey, error) {
+	pemBlock, _ := pem.Decode([]byte(s))
+	if pemBlock == nil {
+		return nil, nil, errors.New("unable to decode pem file")
+	}
+
+	if pemBlock.Type != pemType {
+		return nil, nil, fmt.Errorf("wrong pem block type: %s. Expected SSH-SIGNATURE", pemBlock.Type)
+	}
+
+	// Now we unmarshal it into the Signature block
+	sig := WrappedSig{}
+	if err := ssh.Unmarshal(pemBlock.Bytes, &sig); err != nil {
+		return nil, nil, err
+	}
+
+	if sig.Version != 1 {
+		return nil, nil, fmt.Errorf("unsupported signature version: %d", sig.Version)
+	}
+	if string(sig.MagicHeader[:]) != magicHeader {
+		return nil, nil, fmt.Errorf("invalid magic header: %s", sig.MagicHeader)
+	}
+	if sig.Namespace != "file" {
+		return nil, nil, fmt.Errorf("invalid signature namespace: %s", sig.Namespace)
+	}
+	// TODO: Also check the HashAlgorithm type here.
+
+	// Now we can unpack the Signature and PublicKey blocks
+	sshSig := ssh.Signature{}
+	if err := ssh.Unmarshal([]byte(sig.Signature), &sshSig); err != nil {
+		return nil, nil, err
+	}
+	// TODO: check the format here (should be rsa-sha512)
+
+	pk, err := ssh.ParsePublicKey([]byte(sig.PublicKey))
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return &sshSig, pk, nil
+}
diff --git a/pkg/pki/ssh/sign.go b/pkg/pki/ssh/sign.go
new file mode 100644
index 0000000000000000000000000000000000000000..81e8f80c62184fe580b88e149e76b967f1102232
--- /dev/null
+++ b/pkg/pki/ssh/sign.go
@@ -0,0 +1,77 @@
+package ssh
+
+import (
+	"crypto/rand"
+	"crypto/sha512"
+	"io"
+
+	"golang.org/x/crypto/ssh"
+)
+
+// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig#L81
+type MessageWrapper struct {
+	Namespace     string
+	Reserved      string
+	HashAlgorithm string
+	Hash          string
+}
+
+// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig#L34
+type WrappedSig struct {
+	MagicHeader   [6]byte
+	Version       uint32
+	PublicKey     string
+	Namespace     string
+	Reserved      string
+	HashAlgorithm string
+	Signature     string
+}
+
+const (
+	magicHeader   = "SSHSIG"
+	hashAlgorithm = "sha512"
+)
+
+func sign(s ssh.AlgorithmSigner, m io.Reader) (*ssh.Signature, error) {
+
+	hf := sha512.New()
+	if _, err := io.Copy(hf, m); err != nil {
+		return nil, err
+	}
+	mh := hf.Sum(nil)
+
+	sp := MessageWrapper{
+		Namespace:     "file",
+		HashAlgorithm: hashAlgorithm,
+		Hash:          string(mh),
+	}
+
+	dataMessageWrapper := ssh.Marshal(sp)
+	dataMessageWrapper = append([]byte(magicHeader), dataMessageWrapper...)
+
+	sig, err := s.SignWithAlgorithm(rand.Reader, dataMessageWrapper, ssh.SigAlgoRSASHA2512)
+	if err != nil {
+		return nil, err
+	}
+	return sig, nil
+}
+
+func Sign(sshPrivateKey string, data io.Reader) (string, error) {
+	s, err := ssh.ParsePrivateKey([]byte(sshPrivateKey))
+	if err != nil {
+		return "", err
+	}
+
+	as, ok := s.(ssh.AlgorithmSigner)
+	if !ok {
+		return "", err
+	}
+
+	sig, err := sign(as, data)
+	if err != nil {
+		return "", err
+	}
+
+	armored := Armor(sig, s.PublicKey())
+	return armored, nil
+}
diff --git a/pkg/pki/ssh/sign_test.go b/pkg/pki/ssh/sign_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..66a5c83c5e3c13030497284414874d924b9aef03
--- /dev/null
+++ b/pkg/pki/ssh/sign_test.go
@@ -0,0 +1,238 @@
+package ssh
+
+import (
+	"bytes"
+	"io/ioutil"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+var (
+	// Generated with "ssh-keygen -C test@rekor.dev -f id_rsa"
+	sshPrivateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
+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
+-----END OPENSSH PRIVATE KEY-----
+`
+	sshPublicKey = `ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDXofkiahE7uavjWvxnwkUF27qMgz7pdTwzSv0XzVG6EtirOv3PDWct4YKoXE9c0EqbxnIfYEKwEextdvB7zkgwczdJSHxf/18jQumLn/FuoCmugVSk1H5Qli/qzwBpaTnOk3WuakGuoYUl8ZAokKKgOKLA0aZJ1WRQ2ZCZggA3EkwNZiY17y9Q6HqdgQcH6XN8aAMADNVJdMAJb33hSRJjjsAPTmzBTishP8lYDoGRSsSE7/8XRBCEV5E4I8mI9GElcZwV/1KJx98mpH8QvMzXM1idFcwPRtt1NTAOshwgUU0Fu1x8lU5RQIa6ZKW36qNQLvLxy/BscC7B/mdLptoDs/ot9NimUXZcgCR1a2Q3o7Wi6jIgcgJcyV10Nba81ol4RdN4qPHnVZIzuo+dBkqwG3CMtB4Rj84+Qi+7zyU01hIPreoxQDXaayiGPBUUIiAlW9gsiuRWJzNnu3cvuWDLVfQIkjh7Wug58z+v2NOJ7IMdyERillhzDcvVHaq14+U= test@rekor.dev
+`
+	// Generated with "ssh-keygen -C other-test@rekor.dev -f id_rsa"
+	otherSshPrivateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
+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
+-----END OPENSSH PRIVATE KEY-----
+`
+	otherSshPublicKey = `ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDD9YJJYL1MS8JA7A75PrwS81rc5Ii/VjT6xPy5K/KM/IQ7T6nS3wKD/PLru87GJClTmM3owi2NcCMyZ4KbwYoy3qCT5wOhoB6/9l+O7NIvtFrITGmaEGV6HZfcYouSRcX0AEU1yGVOpIs5mISMOg2lsW/XopPWwToKpbwOPFdCRCT0krrmEsf4HF5Yw0IQlVoKZrhfThomYLvMkCLnIZ55PRIpWoyiFq8X3Q7peJgUJAe7Bc8/Id+hyqC52ZeejPP7oPprEkpkzBCw2ndYq6Y6OXNafEEIIHWXaM3pFqDxonbbvuIwVdHCNMv/yNoSxbgqTKwN/QaNXb+KpuvSrlvRqsNhu/sKsYFH64fTAbP9miDXHmJkA05uFlQukOstUmJ0QxzbsdcFvs8yw0PLsEZhEHXJzR3TLzenyZSq86VZICvGfVacBk7TikCBOtyWuESHhlc6SfZKfzZ67cOlDyKeSiVjgh+eEh9s4h56ahQ2rW05Sq6GjD0YtEzog2J4csE= other-test@rekor.dev
+`
+)
+
+func TestFromOpenSSH(t *testing.T) {
+	// Test that a signature from the cli can validate here.
+	td := t.TempDir()
+
+	data := []byte("hello, ssh world")
+	dataPath := write(t, []byte(data), td, "data")
+	privPath := write(t, []byte(sshPrivateKey), td, "id_rsa")
+	write(t, []byte(sshPublicKey), td, "id_rsa.pub")
+
+	sigPath := dataPath + ".sig"
+	run(t, nil, "ssh-keygen", "-Y", "sign", "-n", "file", "-f", privPath, dataPath)
+
+	sigBytes, err := ioutil.ReadFile(sigPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := Verify(bytes.NewReader(data), string(sigBytes), []byte(sshPublicKey)); err != nil {
+		t.Error(err)
+	}
+
+	// It should not verify if we check against the other public key
+	if err := Verify(bytes.NewReader(data), string(sigBytes), []byte(otherSshPublicKey)); err == nil {
+		t.Error("expected error with incorrect key")
+	}
+
+	// It should not verify if the data is tampered
+	if err := Verify(strings.NewReader("bad data"), string(sigBytes), []byte(sshPublicKey)); err == nil {
+		t.Error("expected error with incorrect data")
+	}
+}
+
+func TestToOpenSSH(t *testing.T) {
+	// Test that a signature from here can validate in the CLI.
+	td := t.TempDir()
+
+	data := []byte("hello, ssh world")
+	write(t, []byte(data), td, "data")
+	write(t, []byte(sshPrivateKey), td, "id_rsa")
+
+	armored, err := Sign(sshPrivateKey, bytes.NewReader(data))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	sigPath := write(t, []byte(armored), td, "oursig")
+
+	// Create an allowed_signers file with two keys to check against.
+	allowedSigner := "test@rekor.dev " + sshPublicKey + "\n"
+	allowedSigner += "othertest@rekor.dev " + sshPrivateKey + "\n"
+	allowedSigners := write(t, []byte(allowedSigner), td, "allowed_signer")
+
+	// We use the correct principal here so it should work.
+	run(t, data, "ssh-keygen", "-Y", "verify", "-f", allowedSigners,
+		"-I", "test@rekor.dev", "-n", "file", "-s", sigPath)
+
+	// Just to be sure, check against the other public key as well.
+	runErr(t, data, "ssh-keygen", "-Y", "verify", "-f", allowedSigners,
+		"-I", "othertest@rekor.dev", "-n", "file", "-s", sigPath)
+
+	// It should error if we run it against other data
+	data = []byte("other data!")
+	runErr(t, data, "ssh-keygen", "-Y", "check-novalidate", "-n", "file", "-s", sigPath)
+}
+
+func TestRoundTrip(t *testing.T) {
+	data := []byte("my good data to be signed!")
+
+	// Create two signatures from two private keys.
+	sig, err := Sign(sshPrivateKey, bytes.NewReader(data))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	otherSig, err := Sign(otherSshPrivateKey, bytes.NewReader(data))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Check the signature against that data and public key
+	if err := Verify(bytes.NewReader(data), sig, []byte(sshPublicKey)); err != nil {
+		t.Error(err)
+	}
+
+	// Now check it against invalid data.
+	if err := Verify(strings.NewReader("invalid data!"), sig, []byte(sshPublicKey)); err == nil {
+		t.Error("expected error!")
+	}
+
+	// Now check it against the wrong key.
+	if err := Verify(bytes.NewReader(data), sig, []byte(otherSshPublicKey)); err == nil {
+		t.Error("expected error!")
+	}
+
+	// Now check it against an invalid signature data.
+	if err := Verify(bytes.NewReader(data), "invalid signature!", []byte(sshPublicKey)); err == nil {
+		t.Error("expected error!")
+	}
+
+	// Once more, use the wrong signature and check it against the original (wrong public key)
+	if err := Verify(bytes.NewReader(data), otherSig, []byte(sshPublicKey)); err == nil {
+		t.Error("expected error!")
+	}
+	// It should work against the correct public key.
+	if err := Verify(bytes.NewReader(data), otherSig, []byte(otherSshPublicKey)); err != nil {
+		t.Error(err)
+	}
+
+}
+
+func write(t *testing.T, d []byte, fp ...string) string {
+	p := filepath.Join(fp...)
+	if err := ioutil.WriteFile(p, d, 0600); err != nil {
+		t.Fatal(err)
+	}
+	return p
+}
+
+func run(t *testing.T, stdin []byte, args ...string) {
+	t.Helper()
+	/* #nosec */
+	cmd := exec.Command(args[0], args[1:]...)
+	cmd.Stdin = bytes.NewReader(stdin)
+	out, err := cmd.CombinedOutput()
+	t.Logf("cmd %v: %s", cmd, string(out))
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func runErr(t *testing.T, stdin []byte, args ...string) {
+	t.Helper()
+	/* #nosec */
+	cmd := exec.Command(args[0], args[1:]...)
+	cmd.Stdin = bytes.NewReader(stdin)
+	out, err := cmd.CombinedOutput()
+	t.Logf("cmd %v: %s", cmd, string(out))
+	if err == nil {
+		t.Fatal("expected error")
+	}
+}
diff --git a/pkg/pki/ssh/verify.go b/pkg/pki/ssh/verify.go
new file mode 100644
index 0000000000000000000000000000000000000000..9281fa216152da737f5db554c7efb7b1db08ec2f
--- /dev/null
+++ b/pkg/pki/ssh/verify.go
@@ -0,0 +1,36 @@
+package ssh
+
+import (
+	"crypto/sha512"
+	"io"
+
+	"golang.org/x/crypto/ssh"
+)
+
+func Verify(message io.Reader, armoredSignature string, publicKey []byte) error {
+	decodedSignature, _, err := Decode(armoredSignature)
+	if err != nil {
+		return err
+	}
+
+	desiredPk, _, _, _, err := ssh.ParseAuthorizedKey(publicKey)
+	if err != nil {
+		return err
+	}
+
+	// Hash the message so we can verify it against the signature.
+	h := sha512.New()
+	if _, err := io.Copy(h, message); err != nil {
+		return err
+	}
+	hm := h.Sum(nil)
+
+	toVerify := MessageWrapper{
+		Namespace:     "file",
+		HashAlgorithm: "sha512",
+		Hash:          string(hm),
+	}
+	signedMessage := ssh.Marshal(toVerify)
+	signedMessage = append([]byte(magicHeader), signedMessage...)
+	return desiredPk.Verify(signedMessage, decodedSignature)
+}