diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfb2b40..ef400a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ jobs: strategy: fail-fast: false matrix: - go: [1.16.x, 1.17.x] + go: [1.16.x, 1.17.x, 1.18.x] os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: diff --git a/README.md b/README.md index fed0de9..f399891 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,6 @@ If everything succeeds, the value in `parsedToken` will be equivalent to that in ## Version 4 Version 4 is fully supported. ## Version 3 -Version 3 supports only local mode (so far). +Version 3 is fully supported. ## Version 2 Version 2 is fully supported. diff --git a/claims_test.go b/claims_test.go index 4dfda88..6dbde94 100644 --- a/claims_test.go +++ b/claims_test.go @@ -54,9 +54,12 @@ func TestAllClaimsPassV3(t *testing.T) { token.SetIssuedAt(time.Now()) key := NewV3SymmetricKey() + secretKey := NewV3AsymmetricSecretKey() encrypted := token.V3Encrypt(key, nil) + signed := token.V3Sign(secretKey, nil) + parser := NewParser() parser.AddRule(ForAudience("a")) parser.AddRule(IdentifiedBy("b")) @@ -67,6 +70,9 @@ func TestAllClaimsPassV3(t *testing.T) { _, err := parser.ParseV3Local(key, encrypted, nil) require.NoError(t, err) + + _, err = parser.ParseV3Public(secretKey.Public(), signed, nil) + require.NoError(t, err) } func TestAllClaimsPassV4(t *testing.T) { diff --git a/message.go b/message.go index 4a2fe4a..5499924 100644 --- a/message.go +++ b/message.go @@ -122,6 +122,18 @@ func (m message) v2Decrypt(key V2SymmetricKey) (*Token, error) { return packet.token() } +// V3Verify will verify a v4 public paseto message. Will return a pointer to +// the verified token (but not validated with rules) if successful, or error in +// the event of failure. +func (m message) v3Verify(key V3AsymmetricPublicKey, implicit []byte) (*Token, error) { + packet, err := v3PublicVerify(m, key, implicit) + if err != nil { + return nil, err + } + + return packet.token() +} + // V3Decrypt will decrypt a v3 local paseto message. Will return a pointer to // the decrypted token (but not validated with rules) if successful, or error in // the event of failure. diff --git a/parser.go b/parser.go index 62a95a5..f4b9e32 100644 --- a/parser.go +++ b/parser.go @@ -79,6 +79,22 @@ func (p Parser) ParseV3Local(key V3SymmetricKey, tainted string, implicit []byte return p.validate(*token) } +// ParseV3Public will parse and verify a v3 public paseto and validate against +// any parser rules. Error if parsing, verification, or any rule fails. +func (p Parser) ParseV3Public(key V3AsymmetricPublicKey, tainted string, implicit []byte) (*Token, error) { + message, err := newMessage(V3Public, tainted) + if err != nil { + return nil, err + } + + token, err := message.v3Verify(key, implicit) + if err != nil { + return nil, err + } + + return p.validate(*token) +} + // ParseV4Local will parse and decrypt a v4 local paseto and validate against // any parser rules. Error if parsing, decryption, or any rule fails. func (p Parser) ParseV4Local(key V4SymmetricKey, tainted string, implicit []byte) (*Token, error) { diff --git a/paseto.go b/paseto.go index 672ea3f..767a46d 100644 --- a/paseto.go +++ b/paseto.go @@ -34,6 +34,8 @@ var ( V2Public = Protocol{Version2, Public} // V3Local represents a v3 protocol in local mode V3Local = Protocol{Version3, Local} + // V3Public represents a v3 protocol in public mode + V3Public = Protocol{Version3, Public} // V4Local represents a v4 protocol in local mode V4Local = Protocol{Version4, Local} // V4Public represents a v4 protocol in public mode @@ -67,6 +69,8 @@ func NewProtocol(version Version, purpose Purpose) (Protocol, error) { return Protocol{}, errors.New("Unsupported PASETO purpose") case Local: return V3Local, nil + case Public: + return V2Public, nil } case Version4: switch purpose { @@ -114,6 +118,8 @@ func (p Protocol) newPayload(bytes []byte) (payload, error) { return nil, errors.New("Unsupported PASETO purpose") case Local: return newV3LocalPayload(bytes) + case Public: + return newV3PublicPayload(bytes) } case Version4: switch p.purpose { @@ -141,6 +147,8 @@ func protocolForPayload(payload payload) (Protocol, error) { return V2Public, nil case v3LocalPayload: return V3Local, nil + case v3PublicPayload: + return V3Public, nil case v4LocalPayload: return V4Local, nil case v4PublicPayload: diff --git a/token.go b/token.go index a2c9efd..a93dcd2 100644 --- a/token.go +++ b/token.go @@ -165,6 +165,15 @@ func (t Token) V2Encrypt(key V2SymmetricKey) string { return v2LocalEncrypt(t.packet(), key, nil).encoded() } +// V3Sign signs the token, using the given key and implicit bytes. Implicit +// bytes are bytes used to calculate the signature, but which are not present in +// the final token. +// Implicit must be reprovided for successful verification, and can not be +// recovered. +func (t Token) V3Sign(key V3AsymmetricSecretKey, implicit []byte) string { + return v3PublicSign(t.packet(), key, implicit).encoded() +} + // V3Encrypt signs the token, using the given key and implicit bytes. Implicit // bytes are bytes used to calculate the encrypted token, but which are not // present in the final token (or its decrypted value). diff --git a/v3.go b/v3.go index 53dc1e0..d984124 100644 --- a/v3.go +++ b/v3.go @@ -3,14 +3,71 @@ package paseto import ( "crypto/aes" "crypto/cipher" + "crypto/ecdsa" "crypto/hmac" + "crypto/rand" "crypto/sha512" + "math/big" "aidanwoods.dev/go-paseto/internal/encoding" "aidanwoods.dev/go-paseto/internal/random" "github.com/pkg/errors" ) +func v3PublicSign(packet packet, key V3AsymmetricSecretKey, implicit []byte) message { + data, footer := packet.content, packet.footer + header := []byte(V3Public.Header()) + + m2 := encoding.Pae(key.Public().compressed(), header, data, footer, implicit) + + hash := sha512.Sum384(m2) + + r, s, err := ecdsa.Sign(rand.Reader, &key.material, hash[:]) + if err != nil { + panic("Failed to sign") + } + + var rBytes [48]byte + var sBytes [48]byte + + r.FillBytes(rBytes[:]) + s.FillBytes(sBytes[:]) + + sig := append(rBytes[:], sBytes[:]...) + + if len(sig) != 96 { + panic("Bad signature length") + } + + var signature [96]byte + copy(signature[:], sig) + + return newMessageFromPayload(v3PublicPayload{data, signature}, footer) +} + +func v3PublicVerify(msg message, key V3AsymmetricPublicKey, implicit []byte) (packet, error) { + payload, ok := msg.p.(v3PublicPayload) + if msg.header() != V3Public.Header() || !ok { + return packet{}, errors.Errorf("Cannot decrypt message with header: %s", msg.header()) + } + + header, footer := []byte(msg.header()), msg.footer + data := payload.message + + m2 := encoding.Pae(key.compressed(), header, data, footer, implicit) + + hash := sha512.Sum384(m2) + + r := new(big.Int).SetBytes(payload.signature[:48]) + s := new(big.Int).SetBytes(payload.signature[48:]) + + if !ecdsa.Verify(&key.material, hash[:], r, s) { + return packet{}, errors.Errorf("Bad signature") + } + + return packet{data, footer}, nil +} + func v3LocalEncrypt(p packet, key V3SymmetricKey, implicit []byte, unitTestNonce []byte) message { var nonce [32]byte random.UseProvidedOrFillBytes(unitTestNonce, nonce[:]) diff --git a/v3_keys.go b/v3_keys.go index 5ebf199..9b1fef4 100644 --- a/v3_keys.go +++ b/v3_keys.go @@ -1,15 +1,112 @@ package paseto import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/sha512" "encoding/hex" "io" + "math/big" "aidanwoods.dev/go-paseto/internal/random" "github.com/pkg/errors" "golang.org/x/crypto/hkdf" ) +// V3AsymmetricPublicKey v3 public public key +type V3AsymmetricPublicKey struct { + material ecdsa.PublicKey +} + +// NewV3AsymmetricPublicKeyFromHex Construct a v3 public key from hex +func NewV3AsymmetricPublicKeyFromHex(hexEncoded string) (V3AsymmetricPublicKey, error) { + publicKeyBytes, err := hex.DecodeString(hexEncoded) + + if err != nil { + // even though we return error, return a random key here rather than + // a nil key + return NewV3AsymmetricSecretKey().Public(), err + } + + if len(publicKeyBytes) != 49 { + // even though we return error, return a random key here rather than + // a nil key + return NewV3AsymmetricSecretKey().Public(), errors.New("Key incorrect length") + } + + publicKey := new(ecdsa.PublicKey) + publicKey.Curve = elliptic.P384() + publicKey.X, publicKey.Y = elliptic.UnmarshalCompressed(elliptic.P384(), publicKeyBytes) + + return V3AsymmetricPublicKey{*publicKey}, nil +} + +func (k V3AsymmetricPublicKey) compressed() []byte { + return elliptic.MarshalCompressed(elliptic.P384(), k.material.X, k.material.Y) +} + +// ExportHex export a V3AsymmetricPublicKey to hex for storage +func (k V3AsymmetricPublicKey) ExportHex() string { + return hex.EncodeToString(k.compressed()) +} + +// V3AsymmetricSecretKey v3 public private key +type V3AsymmetricSecretKey struct { + material ecdsa.PrivateKey +} + +// Public returns the corresponding public key for a secret key +func (k V3AsymmetricSecretKey) Public() V3AsymmetricPublicKey { + return V3AsymmetricPublicKey{k.material.PublicKey} +} + +// ExportHex export a V3AsymmetricSecretKey to hex for storage +func (k V3AsymmetricSecretKey) ExportHex() string { + return hex.EncodeToString(k.material.D.Bytes()) +} + +// NewV3AsymmetricSecretKey generate a new secret key for use with asymmetric +// cryptography. Don't forget to export the public key for sharing, DO NOT share +// this secret key. +func NewV3AsymmetricSecretKey() V3AsymmetricSecretKey { + privateKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + + if err != nil { + panic("CSPRNG failure") + } + + return V3AsymmetricSecretKey{*privateKey} +} + +// NewV3AsymmetricSecretKeyFromHex creates a secret key from hex +func NewV3AsymmetricSecretKeyFromHex(hexEncoded string) (V3AsymmetricSecretKey, error) { + secretBytes, err := hex.DecodeString(hexEncoded) + + if err != nil { + // even though we return error, return a random key here rather than + // a nil key + return NewV3AsymmetricSecretKey(), err + } + + if len(secretBytes) != 48 { + // even though we return error, return a random key here rather than + // a nil key + return NewV3AsymmetricSecretKey(), errors.New("Key incorrect length") + } + + privateKey := new(ecdsa.PrivateKey) + privateKey.D = new(big.Int).SetBytes(secretBytes) + + publicKey := new(ecdsa.PublicKey) + publicKey.Curve = elliptic.P384() + publicKey.X, publicKey.Y = publicKey.Curve.ScalarBaseMult(privateKey.D.Bytes()) + + privateKey.PublicKey = *publicKey + + return V3AsymmetricSecretKey{*privateKey}, nil +} + // V3SymmetricKey v3 local symmetric key type V3SymmetricKey struct { material [32]byte diff --git a/v3_payloads.go b/v3_payloads.go index 65d3dae..c9470cd 100644 --- a/v3_payloads.go +++ b/v3_payloads.go @@ -4,6 +4,31 @@ import ( "github.com/pkg/errors" ) +type v3PublicPayload struct { + message []byte + signature [96]byte +} + +func (p v3PublicPayload) bytes() []byte { + return append(p.message, p.signature[:]...) +} + +func newV3PublicPayload(bytes []byte) (v3PublicPayload, error) { + signatureOffset := len(bytes) - 96 + + if signatureOffset < 0 { + return v3PublicPayload{}, errors.New("Payload is not long enough to be a valid Paseto message") + } + + message := make([]byte, len(bytes)-96) + copy(message, bytes[:signatureOffset]) + + var signature [96]byte + copy(signature[:], bytes[signatureOffset:]) + + return v3PublicPayload{message, signature}, nil +} + type v3LocalPayload struct { nonce [32]byte cipherText []byte diff --git a/vectors_test.go b/vectors_test.go index d03dc27..b8d89e7 100644 --- a/vectors_test.go +++ b/vectors_test.go @@ -3,6 +3,7 @@ package paseto import ( "encoding/hex" "encoding/json" + "fmt" "os" "testing" @@ -146,8 +147,22 @@ func TestV3(t *testing.T) { // Public mode case "": - t.Log("Skipping...") - return + pk, err := NewV3AsymmetricPublicKeyFromHex(test.PublicKey) + require.NoError(t, err) + + message, err := newMessage(V3Public, test.Token) + if test.ExpectFail { + require.Error(t, err) + return + } + require.NoError(t, err) + + decoded, err = v3PublicVerify(message, pk, []byte(test.ImplicitAssertation)) + if test.ExpectFail { + require.Error(t, err) + return + } + require.NoError(t, err) } require.Equal(t, test.Payload, string(decoded.content)) @@ -172,13 +187,32 @@ func TestV3(t *testing.T) { // Public mode case "": - t.Log("Skipping...") - return + sk, err := NewV3AsymmetricSecretKeyFromHex(test.SecretKey) + require.NoError(t, err) + + signed := v3PublicSign(packet, sk, implicit) + + // v3 signatures are not deterministic in this implementation, so just check that something signed can be verified + + pk, err := NewV3AsymmetricPublicKeyFromHex(test.PublicKey) + require.NoError(t, err) + + decoded, err = v3PublicVerify(signed, pk, []byte(test.ImplicitAssertation)) + require.NoError(t, err) + + require.JSONEq(t, test.Payload, string(decoded.content)) + require.Equal(t, test.Footer, string(decoded.footer)) } }) } } +func TestV3SigLenIncorrect(t *testing.T) { + for i := 0; i < 100; i++ { + t.Run(fmt.Sprintf("V3 run %d", i), TestV3) + } +} + func TestV4(t *testing.T) { data, err := os.ReadFile("test-vectors/v4.json") require.NoError(t, err)