Started writing an implementation of CryptoAuth using PGP

This commit is contained in:
Zack Mullaly 2018-09-20 18:34:03 -04:00
Родитель 38e094c35a
Коммит 43a0a444ee
2 изменённых файлов: 454 добавлений и 0 удалений

226
auth/pgp.go Normal file
Просмотреть файл

@ -0,0 +1,226 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Contributor: Zack Mullaly <zmullaly@mozilla.com>
package auth
import (
"bytes"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"os"
"strings"
"golang.org/x/crypto/openpgp"
"golang.org/x/crypto/openpgp/armor"
)
// PGPAuth implements CryptoAuth to provide crypto services using PGP.
type PGPAuth struct {
secring *io.ReadSeeker
cachedPassphrase string
}
// NewPGPAuth constructs a new PGPAuth instance that will load keys
// from a secring given by its path.
func NewPGPAuth(secring *io.ReadSeeker, passphrase string) PGPAuth {
return PGPAuth{
secring: secring,
cachedPassphrase: "",
}
}
// Identifier specifies which implementation of CryptoAuth this is.
func (pgp *PGPAuth) Identifier() AuthSchemeId {
return AuthSchemePGP
}
// Sign creates a signature over a message using a particular key.
func (pgp *PGPAuth) Sign(key Fingerprint, message []byte) ([]byte, error) {
pgp.secring.Seek(0, 0)
keyring, err := openpgp.ReadKeyRing(pgp.secring)
if err != nil {
return []byte{}, err
}
var signer *openpgp.Entity
found := false
for _, entity := range keyring {
if entity.PrivateKey == nil {
return fmt.Errorf("secring contains an entity without private key data")
}
fingerprint := strings.ToUpper(
hex.EncodeToString(entity.PrivateKey.PublicKey.Fingerprint[:]))
if key == fingerprint {
signer = entity
found = true
break
}
}
if !found {
return fmt.Errorf("Signer '%s' not found", key)
}
// If the private key is encrypted, try to decrypt it with a cached passphrase
// then try with an agent or by asking the user for a passphrase.
if signer.PrivateKey.Encrypted {
err = signer.PrivateKey.Decrypt([]byte(pgp.cachedPassphrase))
if err != nil {
var pass string
signer, pass, err = decryptEntity(signer)
if err != nil {
return []byte{}, err
}
if pass != "" {
cachedPassphrase = pass
}
}
}
out := bytes.NewBuffer(nil)
msg := bytes.NewBuffer(message)
err = openpgp.ArmoredDetachSign(out, signer, msg, nil)
if err != nil {
return fmt.Errorf("Signing failed: '%s'", err.Error())
}
sig, err := deArmor(out.String())
if err != nil {
return fmt.Errorf("Error converting signature to string: '%s'", err.Error())
}
decodedSig, err := base64.StdEncoding.DecodeString(sig)
if err != nil {
return fmt.Errorf("Error decoding signature from base64: '%s'", err.Error())
}
return sig, nil
}
// Verify determines if a signature over a message is valid. If the signature
// is valid, nil will be returned.
func (pgp *PGPAuth) Verify(key Fingerprint, signature, message []byte) error {
var err error
pgp.secring.Seek(0, 0)
sig := reArmorSignature(base64.StdEncoding.EncodeToString(signature))
sigReader := strings.NewReader(sig)
sigBlock, err := armor.Decode(sigReader)
if err != nil {
return err
}
if sigBlock.Type != "PGP SIGNATURE" {
return fmt.Errorf("Wrong signature type '%s'", sigBlock.Type)
}
srcReader := bytes.NewReader(message)
ring, err := openpgp.ReadKeyRing(pgp.keyring)
if err != nil {
return err
}
_, err := openpgp.CheckDetachedSignature(ring, srcReader, sigBlock.Body)
if err != nil {
return err
}
return nil
}
// decryptEntity calls gnupg-agent and pinentry to obtain a passphrase and
// decrypt the private key of a given entity (thank you, camlistore folks)
func decryptEntity(s *openpgp.Entity) (ds *openpgp.Entity, pass string, err error) {
defer func() {
if e := recover(); e != nil {
err = fmt.Errorf("pgp.decryptEntity(): %v", e)
}
}()
ds = s
// TODO: syscall.Mlock a region and keep pass phrase in it.
pubk := &ds.PrivateKey.PublicKey
desc := fmt.Sprintf("Need to unlock GPG key %s to use it for signing.",
pubk.KeyIdShortString())
conn, err := gpgagent.NewConn()
switch err {
case gpgagent.ErrNoAgent:
fmt.Fprintf(os.Stderr, "Note: gpg-agent not found; resorting to on-demand password entry.\n")
case nil:
defer conn.Close()
req := &gpgagent.PassphraseRequest{
CacheKey: "mig:pgpsign:" + pubk.KeyIdShortString(),
Prompt: "Passphrase",
Desc: desc,
}
for tries := 0; tries < 3; tries++ {
pass, err := conn.GetPassphrase(req)
if err == nil {
err = ds.PrivateKey.Decrypt([]byte(pass))
if err == nil {
return ds, pass, err
}
req.Error = "Passphrase failed to decrypt: " + err.Error()
conn.RemoveFromCache(req.CacheKey)
continue
}
if err == gpgagent.ErrCancel {
panic("failed to decrypt key; action canceled")
}
}
default:
panic(err)
}
pinReq := &pinentry.Request{Desc: desc, Prompt: "Passphrase"}
for tries := 0; tries < 3; tries++ {
pass, err = pinReq.GetPIN()
if err == nil {
err = ds.PrivateKey.Decrypt([]byte(pass))
if err == nil {
return ds, pass, err
}
pinReq.Error = "Passphrase failed to decrypt: " + err.Error()
continue
}
if err == pinentry.ErrCancel {
panic("failed to decrypt key; action canceled")
}
}
return ds, "", fmt.Errorf("decryptEntity(): failed to decrypt key %q: %v", pubk.KeyIdShortString(), err)
}
// deArmor takes a multi line armored GPG signature, and turns it back
// into a single line signature (thank you, camlistore folks)
func deArmor(sig string) (str string, err error) {
index1 := strings.Index(sig, "\n\n")
index2 := strings.Index(sig, "\n-----")
if index1 == -1 || index2 == -1 {
err = fmt.Errorf("Failed to parse signature from gpg.")
return
}
inner := sig[index1+2 : index2]
str = strings.Replace(inner, "\n", "", -1)
return
}
// reArmorSignature takes a single line armor and turns it back into an PGP-style
// multi-line armored string (thank you, camlistore folks)
func reArmorSignature(line string) string {
lastEq := strings.LastIndex(line, "=")
if lastEq == -1 {
return ""
}
buf := new(bytes.Buffer)
fmt.Fprintf(buf, "-----BEGIN PGP SIGNATURE-----\n\n")
payload := line[0:lastEq]
crc := line[lastEq:]
for len(payload) > 0 {
chunkLen := len(payload)
if chunkLen > 64 {
chunkLen = 64
}
fmt.Fprintf(buf, "%s\n", payload[0:chunkLen])
payload = payload[chunkLen:]
}
fmt.Fprintf(buf, "%s\n-----END PGP SIGNATURE-----", crc)
return buf.String()
}

228
auth/pgp_test.go Normal file
Просмотреть файл

@ -0,0 +1,228 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Contributor: Zack Mullaly <zmullaly@mozilla.com>
package auth
import (
"bytes"
"encoding/base64"
"testing"
)
type testSecring struct {
Email string
Fingerprint string
SecringB64 string
}
func TestPGPAuth(t *testing.T) {
testCases := []struct {
Description string
Message string
KeyData testSecring
Passphrase string
ShouldCachePassphrase bool
ExpectSigningToSucceed bool
ExpectSignatureToValidate bool
}{
{
Description: `
If a key doesn't use a passphrase, not caching one should not cause
verification to fail.
`,
Message: "test data 1",
KeyData: testRingNoPassphrase,
Passphrase: "",
ShouldCachePassphrase: false,
ExpectSigningToSucceed: true,
ExpectSignatureToValidate: true,
},
{
Description: `
If a key doesn't use a passphrase, caching one should still allow verification
to succeed.
`,
Message: "test data 2",
KeyData: testRingNoPassphrase,
Passphrase: "notN3cess4ry",
ShouldCachePassphrase: true,
ExpectSigningToSucceed: true,
ExpectSignatureToValidate: true,
},
{
Description: `
If a key does use a passphrase, caching the right one should allow verification
to succeed.
`,
Message: "test data 3",
KeyData: testRingWithPassphrase,
Passphrase: "testing123",
ShouldCachePassphrase: true,
ExpectSigningToSucceed: true,
ExpectSignatureToValidate: true,
},
{
Description: `
If a key does use a passphrase, not caching it should cause signing to fail.
`,
Message: "test data 4",
KeyData: testRingWithPassphrase,
Passphrase: "wrong",
ShouldCachePassphrase: false,
ExpectSigningToSucceed: false,
ExpectSignatureToValidate: false,
},
}
for testCase, caseNum := range testCases {
t.Logf("Running TestPGPAuth case #%d: %s", caseNum, testCase.Description)
decodedSecring, _ := base64.StdEncoding.DecodeString(testCase.KeyData.Secringb64)
secring := bytes.NewReader(decodedSecring)
passphrase := ""
if testCase.ShouldCachePassphrase {
passphrase = testCase.Passphrase
}
authenticator := NewPGPAuth(secring, passphrase)
keyFingerprint := Fingerprint(testCase.KeyData.Fingerprint)
message := []byte(testCase.Message)
signature, err := authenticator.Sign(keyFingerprint, message)
if err != nil && testCase.ExpectSigningToSucceed {
t.Errorf("Expected signing to succeed, but got error '%s'", err.Error())
} else if err == nil && !testCase.ExpectSigningToSucceed {
t.Errorf("Expected signing to fail, but it succeeded.")
}
err = authenticator.Verify(keyFingerprint, signature, message)
if err != nil && testCase.ExpectSignatureToValidate {
t.Errorf("Expected verification to suceed, but got error '%s'", err.Error())
} else if err == nil && !testCase.ExpectSignatureToValidate {
t.Errorf("Expected verification to fail, but it succeeded.")
}
}
}
// Generated without a passphrase
var testRingNoPassphrase testSecring = testSecring{
Email: "mig@test.com",
Fingerprint: "998A3BAEF09BF48E137E749419977578AC858169",
SecringB64: `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
Dx4ORSR0+rE8oKwGNPIXIGo7YsawKhhLyg==`,
}
// Generated with passphrase "testing123"
var testRingWithPassphrase testSecring = testSecring{
Email: "mig@test2.com",
Fingerprint: "A823E24C1A095176642131F605D10E57C9430F32",
SecringB64: `lQPGBFukE1MBCAC+DQXhZiz42LANoXMppdikqSkp/X3kDBGvcjeTIXb0DPXJog3N
Csls1uUK0jEHdawzUIFN915GyoHgU0J9RlmFlg6P2pOlZUZxsAOMmToof/RK90y9
Ys9BQfhT7LGFgebqhQL5w2TNtbezomITMdN9eZAeiAOoEhhlFOsy8+3UW5CvqxzL
h6CqH4o/GNDRqGgHGqUwgyTmHTMcLsW/C6hObuwDWcN2dWcOQF9X9K1SKAS9SKKu
Qrgo5E9/G5eZEGZK8GEyLiSoUBaHykkivbpy820NE6F6yAKF7MhutRaIgfED13aK
8XbQQjnF8tv5+ot1qABKlBJB9thHyAfrKqJ1ABEBAAH+BwMCsETmybO3Q///l1FX
fhAunjN0pEjPr3c2KaB9U9yKj+iqH/4lFhNxZHOe0yy1tQUkhoTmUkB4K2bOM9YT
JykCdrCttLche7kPguIBaY5FiglUDkTJaVxpkvT9XJohFelrByePs6mtUAs0GC7N
EUX8jpG+HbKy1WOs6ek3VDqW5dTdXERo9e4x4ZaNim1jSDfRpckBkcF+tPpJh1/X
I+ffETX1s915l9P+/kyjSTxj3T92DzgL+3wNBMybB9ta8ZH8GMwsVCnSQARqZaOq
Geol6PikxhO8T7DKejz3w0bzUsJw2pRiwZpwl1uaegXO59WFgfkRv4aj4xGKI46m
KHKz8O/WHrYh8fLUhfJqVZ2sNbLOmP+/LAMPR6PESAbgsRq9R1yACz9EftUggjq7
ZXwqxgYRVK6PNu+zcCZ4Bek5xUYesK5OlBh+zlGK9vKiWQtsPvx4xMb1h4Xcp2HV
gKPk813DkPmrDNjXU/ZFwvbMsiIw/Wl+t05icI5m1uyiyX+DfHlXObfL0Ysw7bhs
awEKWGdW0s3bFE90D7naiSIujcbCWiTpvfgUoodSF1x3N8OcLgO3StLDL7s/B3bE
ikn0aY+/3QY/8fvdpkrcLoiW0rGxzZyyy8UQoySJYqK9v1x6DL6i4s+JTClfptEw
J05SuqkeDyd2P0R/ZTqKGlJKaywVWsHlkTcSsQKbIe3ezCPvLjLMecvbO30pjtvB
X0qZs3DCp7aV9uRyr78OF/ryrqsfeNgVnfJ14MJGpSZOiHPWZUu+IrmbGWLOhflD
4700q2w6/xp5fBSaR16Q6dDTVxPR5MvyNZDD+tJSBJkdxzNwxSh/kHv2WVlQomj6
I9fO6FolO0vKKBe6bc5iBkiznCpLUtaXO0AJZUU9NJaKTuZ9fX35n1yDygmGnsvu
4RGeToD/57u2tBlNaWcgVGVzdDIgPG1pZ0B0ZXN0Mi5jb20+iQFOBBMBCgA4FiEE
qCPiTBoJUXZkITH2BdEOV8lDDzIFAlukE1MCGwMFCwkIBwIGFQoJCAsCBBYCAwEC
HgECF4AACgkQBdEOV8lDDzJLDQf9Eak/h1W9H1WalmHEr/4s7/2Kx5ygG9B0cRc4
Q+E2h2sEJWTkJcdrQbiESZae/moBj0e+gxH4R2BGxk42U0derZu5Fh3PcuvaBknT
drJIlO+jiWGc2FQ3LAm9qt1Ua7thEyDFUvW6DI/Stf/TeruNti6YV+Qdk33Ri/AY
Yer4qlPGDlZoQa5fbYVXOo29Zs3Y6r0ZGuTbnoEhaoywGdv8NcND4Xmx0tm4DTti
zB0jwPaB7KZtGNeJvFqUtvaS7yA8znwH5RToivo1jdvprKc7WaYXsD3cEz7LwHUY
GJ0Y6PKf58+ITSj2TF7msT56z6LLovLOG+bNwxwhXJiNm7Zy6Z0DxgRbpBNTAQgA
zfCeuhSrzrVLXJKnCOt7dC75WJWrDnfCyBvsaYPWgxACE2LnQu4MTl7wX0dTxwYL
8/Ap/3w+Czl1JUdUQmavnEagy1N4nvaDMYLSqsVX86fFKKXVxNnX7vcQHvksRS+m
z45PUSSCf7ZqTlGGx48bjfnuqkZI8aMMYgvbRo164bmqxnqD14LOVSCtZ/WtuUn+
2z3y/fHRiph9WQkiBaG/VfGsWrZz2X6jHe2ob/9lNs3CtDvRB32f3dggz1LG/f/O
SpcgBG6OYDpA547cG3JBuM9pkwIT5KKmwgGVmsMjMsqPCPQfDqPKsnGjCKtWmNgA
MoC/QMl5eoik8TLCq5pubwARAQAB/gcDAjt0eklqZWBN/2+cQuF0zCrzLslExgI6
J+ojrKf3SBbZeltHKa5xradPkMRIZn7QAIc96gV95aLUffMygKR0qpKdY+bdxh5F
YCsFsKOF7NEpz1Zd1pMw5XPiquD9T/JuBmpFxwDvexhTD6W5wSO/7lRj+61FUtbp
+MYQEUKsmbtHMYtUHMUhx25t/xDM6vGYTVkzW65LJDZEEkYZb6ORsGPNx2+hekYb
0dI1OFzWikEC6a+keDmRrwZ7gpoKpt/thneTRgyJ0AMpH42QCgOumM8g7bGluApz
flP69a/r0yR0jf4SJRlnGxFaKbeMvVHAZGkXn/3tW99AXTDsw1TzT8xgwsNS3BRa
Z1FLNtBOGQudRAUiNGhKec10T8TkntDzf/0nJDkgLxmWjjQXsDDVfP6z7UZnerD9
uTvMDCYiKsszpKyXjcD/dauVnPWpzNoJzzaOGqwCHmeCHm6/URgLdf/79CLrA1Px
vbt6UadtVY7N4dc0vqckYRg/KYDbHrk8B4wRM0RGWLjvTF/slj+KXvEAJsa3AHSa
dZE+ek79lHyqQ1L7c9riHR3HctYgwdVXvR4oRXQSLNxNlrbD6+hd23BbV5w8DF3u
OUfutQfeVwiQNBy503MWVaUygjxBqC9KvyZQxRBQSQ0+am+ljGG1MKzT8najxu77
jI0LUR4YZIAgbvkpv/a+z/ev062mtI3Mf7dJcsLqqzK3ksuzLP81oZFpEPqHr+Ed
T1URHe6M9bV/sP7MkpHjsJsbA2CLzBj8/7TDDYvRW7hccZlokzV6oYI15in+ecwj
wWR0Vvq0AcadbonErQO8Pw1XV2hygYIGJHgWJRSmXB8UegQpCKe5C8DO8H5wvEhe
bdO0VGKpyEalX0jJxuz7KRxjyjwbQdXATb2s+Er9FYczQc3rgQFBVINyZw/DYYkB
NgQYAQoAIBYhBKgj4kwaCVF2ZCEx9gXRDlfJQw8yBQJbpBNTAhsMAAoJEAXRDlfJ
Qw8yf54H/jl7jupnxuG9Z7QcwnVyz0gZnAuWvm0EWrNo9hqXDDH2RIYCRC+Jxg/y
pvG00sMMIn53mXIhrAf/MXcrgnZ2ms+M3pTOswEcso3OhNtIKOOajQDDpXHezTfG
nVTPpRGlqzRdqxgj9lOzTP1OJYvKems5r+N7jUMZbfpbTGmvGkGl8AIhdjPrUvQ2
Y3AhOCeQou1CPB9ryLk5S50FvAldoZ1Tpun7UxX595dr+j0fcRamQfcm89D7OWRT
ypew0WaGjxEIc/3MO0vO0w4Qjhqgmlui/qMScCmPCiGO360LguYYeVuB+vCkgrBT
JrOZOm2DH0Sxtt6uzXsUGNiqdjj6oco=`,
}