diff options
| author | Shulhan <m.shulhan@gmail.com> | 2020-09-09 17:01:03 +0700 |
|---|---|---|
| committer | Shulhan <m.shulhan@gmail.com> | 2020-09-09 17:01:03 +0700 |
| commit | 50e094f7dccfa4e06c3c9d26265f03a74af003b7 (patch) | |
| tree | 1f5b5fabef19a90be9c835017fc26592b200fbfc /lib/paseto | |
| parent | 18ab9aad4c3a99822b9dfdc37a05dae12f4fb05d (diff) | |
| download | pakakeh.go-50e094f7dccfa4e06c3c9d26265f03a74af003b7.tar.xz | |
paseto: implement strict JSON token validation
* The Issuer field must equal to peer.ID
* The Audience field must equal to our.ID,
* If peer.AllowedSubjects is not empty, the Subject value must be in
one of them,
* The current time must be after IssuedAt field,
* The current time must after NotBefore "nbf" field,
* The current time must before ExpiredAt field.
Diffstat (limited to 'lib/paseto')
| -rw-r--r-- | lib/paseto/example_public_mode_test.go | 45 | ||||
| -rw-r--r-- | lib/paseto/json_footer.go | 2 | ||||
| -rw-r--r-- | lib/paseto/json_token.go | 44 | ||||
| -rw-r--r-- | lib/paseto/key.go | 27 | ||||
| -rw-r--r-- | lib/paseto/keys.go | 40 | ||||
| -rw-r--r-- | lib/paseto/paseto.go | 65 | ||||
| -rw-r--r-- | lib/paseto/public_mode.go | 67 |
7 files changed, 223 insertions, 67 deletions
diff --git a/lib/paseto/example_public_mode_test.go b/lib/paseto/example_public_mode_test.go index a5004a2e..6868a8d1 100644 --- a/lib/paseto/example_public_mode_test.go +++ b/lib/paseto/example_public_mode_test.go @@ -12,35 +12,42 @@ import ( ) func ExamplePublicMode() { + subjectMessage := "message" + senderSK, _ := hex.DecodeString("e9ae9c7eae2fce6fd6727b5ca8df0fbc0aa60a5ffb354d4fdee1729e4e1463688d2160a4dc71a9a697d6ad6424da3f9dd18a259cdd51b0ae2b521e998b82d36e") senderPK, _ := hex.DecodeString("8d2160a4dc71a9a697d6ad6424da3f9dd18a259cdd51b0ae2b521e998b82d36e") senderKey := Key{ - id: "sender", - private: ed25519.PrivateKey(senderSK), - public: ed25519.PublicKey(senderPK), + ID: "sender", + Private: ed25519.PrivateKey(senderSK), + Public: ed25519.PublicKey(senderPK), + AllowedSubjects: map[string]struct{}{ + subjectMessage: struct{}{}, + }, } receiverSK, _ := hex.DecodeString("4983da648bff1fd3e1892df9c56370215aa640829a5cab02d6616b115fa0bc5707c22e74ab9b181f8d87bdf03cf88476ec4c35e5517e173f236592f6695d59f5") receiverPK, _ := hex.DecodeString("07c22e74ab9b181f8d87bdf03cf88476ec4c35e5517e173f236592f6695d59f5") receiverKey := Key{ - id: "receiver", - private: ed25519.PrivateKey(receiverSK), - public: ed25519.PublicKey(receiverPK), + ID: "receiver", + Private: ed25519.PrivateKey(receiverSK), + Public: ed25519.PublicKey(receiverPK), } // // In the sender part, we register the sender key and the public key // of receiver in the list of peers. // - senderPeers := map[string]ed25519.PublicKey{ - receiverKey.id: receiverKey.public, - } - sender := NewPublicMode(senderKey, senderPeers) + sender := NewPublicMode(senderKey) + sender.AddPeer(receiverKey) - addFooter := map[string]interface{}{ + footer := map[string]interface{}{ "FOOTER": "HERE", } - token, err := sender.Pack([]byte("hello receiver"), addFooter) + token, err := sender.Pack(receiverKey.ID, subjectMessage, []byte("hello receiver"), footer) + if err != nil { + log.Fatal(err) + } + invalidToken, err := sender.Pack(receiverKey.ID, "unknown-subject", []byte("hello receiver"), footer) if err != nil { log.Fatal(err) } @@ -52,10 +59,8 @@ func ExamplePublicMode() { // In the receiver part, we register the receiver key and the public key // of sender in the list of peers. // - receiverPeers := map[string]ed25519.PublicKey{ - senderKey.id: senderKey.public, - } - receiver := NewPublicMode(receiverKey, receiverPeers) + receiver := NewPublicMode(receiverKey) + receiver.AddPeer(senderKey) // receiver receive the token from sender and unpack it ... gotData, gotFooter, err := receiver.Unpack(token) @@ -65,7 +70,15 @@ func ExamplePublicMode() { fmt.Printf("Received data: %s\n", gotData) fmt.Printf("Received footer: %+v\n", gotFooter) + + // receiver receive invalid token from sender and unpack it ... + gotData, gotFooter, err = receiver.Unpack(invalidToken) + if err != nil { + fmt.Println(err) + } + // Output: // Received data: hello receiver // Received footer: map[FOOTER:HERE] + // token subject "unknown-subject" is not allowed for key "sender" } diff --git a/lib/paseto/json_footer.go b/lib/paseto/json_footer.go index 6a720051..ef6b28fc 100644 --- a/lib/paseto/json_footer.go +++ b/lib/paseto/json_footer.go @@ -6,5 +6,5 @@ package paseto type JSONFooter struct { KID string `json:"kid"` - Data map[string]interface{} `json:"data"` + Data map[string]interface{} `json:"data,omitempty"` } diff --git a/lib/paseto/json_token.go b/lib/paseto/json_token.go index 272a1ecf..3958e00f 100644 --- a/lib/paseto/json_token.go +++ b/lib/paseto/json_token.go @@ -25,19 +25,51 @@ type JSONToken struct { } // -// Validate the ExpiredAt and NotBefore time fields. +// Validate the JSON token fields, // -func (jtoken *JSONToken) Validate() (err error) { +// * The Issuer must equaal to peer.ID +// * The Audience must equal to received ID, +// * If peer.AllowedSubjects is not empty, the Subject value must be in +// one of them, +// * The current time must be after IssuedAt field, +// * The current time must after NotBefore "nbf" field, +// * The current time must before ExpiredAt field. +// +// If one of the above condition is not passed, it will return an error. +// +func (jtoken *JSONToken) Validate(audience string, peer Key) (err error) { now := time.Now() - if jtoken.ExpiredAt != nil { - if now.After(*jtoken.ExpiredAt) { - return fmt.Errorf("token is expired") + if jtoken.Issuer != peer.ID { + return fmt.Errorf("expecting issuer %q, got %q", peer.ID, + jtoken.Issuer) + } + if len(peer.AllowedSubjects) != 0 { + _, isAllowed := peer.AllowedSubjects[jtoken.Subject] + if !isAllowed { + return fmt.Errorf("token subject %q is not allowed for key %q", + jtoken.Subject, peer.ID) + } + } + if len(audience) != 0 { + if jtoken.Audience != audience { + return fmt.Errorf("expecting audience %q, got %q", + audience, jtoken.Audience) + } + } + if jtoken.IssuedAt != nil { + if now.Equal(*jtoken.IssuedAt) || now.Before(*jtoken.IssuedAt) { + return fmt.Errorf("token issued at before current time") } } if jtoken.NotBefore != nil { - if now.Before(*jtoken.NotBefore) { + if now.Equal(*jtoken.NotBefore) || now.Before(*jtoken.NotBefore) { return fmt.Errorf("token is too early") } } + if jtoken.ExpiredAt != nil { + if now.Equal(*jtoken.ExpiredAt) || now.After(*jtoken.ExpiredAt) { + return fmt.Errorf("token is expired") + } + } return nil } diff --git a/lib/paseto/key.go b/lib/paseto/key.go index 48a5eaf4..850f720d 100644 --- a/lib/paseto/key.go +++ b/lib/paseto/key.go @@ -7,18 +7,19 @@ package paseto import "crypto/ed25519" type Key struct { - id string - private ed25519.PrivateKey - public ed25519.PublicKey -} + // ID is a unique key ID. + ID string + + // PrivateKey for signing public token. + Private ed25519.PrivateKey + + // PublicKey for verifying public token. + Public ed25519.PublicKey -// -// NewKey create new Key from hex encoded strings. -// -func NewKey(id string, private ed25519.PrivateKey, public ed25519.PublicKey) Key { - return Key{ - id: id, - private: private, - public: public, - } + // AllowedSubjects contains list of subject that are allowed in the + // token's claim "sub" to be signed by this public key. + // This field is used by receiver to check the claim "sub" and compare + // it with this list. + // Empty list means allowing all subjects. + AllowedSubjects map[string]struct{} } diff --git a/lib/paseto/keys.go b/lib/paseto/keys.go new file mode 100644 index 00000000..a9b5a244 --- /dev/null +++ b/lib/paseto/keys.go @@ -0,0 +1,40 @@ +// Copyright 2020, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package paseto + +import "sync" + +// +// keys contains and maintains list of public Keys and its configuration. +// +type keys struct { + sync.Mutex + v map[string]Key +} + +func newKeys() *keys { + return &keys{ + v: make(map[string]Key), + } +} + +func (p *keys) upsert(k Key) { + p.Lock() + p.v[k.ID] = k + p.Unlock() +} + +func (p *keys) get(id string) (k Key, ok bool) { + p.Lock() + k, ok = p.v[id] + p.Unlock() + return +} + +func (p *keys) delete(id string) { + p.Lock() + delete(p.v, id) + p.Unlock() +} diff --git a/lib/paseto/paseto.go b/lib/paseto/paseto.go index 3025d71a..1aa99533 100644 --- a/lib/paseto/paseto.go +++ b/lib/paseto/paseto.go @@ -3,9 +3,9 @@ // license that can be found in the LICENSE file. // -// Package paseto provide a simple, ready to use, implementation of -// Platform-Agnostic SEcurity TOkens (PASETOs) v2 as defined in draft of RFC -// 01 [1]. +// Package paseto provide a simple, ready to use, opinionated implementation +// of Platform-Agnostic SEcurity TOkens (PASETOs) v2 as defined in draft of +// RFC 01 [1]. // // Limitation // @@ -21,24 +21,33 @@ // The public mode focus on signing and verifing data, everything else is // handled and filled automatically. // -// For example, when generating token for signing, the user data is stored -// using key "data" inside the JSON token, encoded using base64. -// The Issuer will be set to the Key's ID, the expiration date is set to -// current time plus TTL. -// The footer will always generated using JSONFooter with KID (Key-ID) set to -// the Key's ID. -// Additional footer data can be added on the Data field. +// Steps for sender when generating new token, the Pack() method, +// +// * Prepare the JSON token claims, set +// ** Issuer "iss" to PublicMode.our.ID +// ** Subject "sub" to subject value from parameter +// ** Audience "aud" to audience value from parameter +// ** IssuedAt to current time +// ** NotBefore to current time +// ** ExpiredAt to current time + 60 seconds +// ** Data field to base64 encoded of data value from parameter +// * Prepare the JSON footer, set +// ** Key ID "kid" to PublicMode.our.ID // -// When verifying token, the key ID is read from footer and verified using one -// of the public key registered previously. +// The user's claims data is stored using key "data" inside the JSON token, +// encoded using base64 (with padding). +// Additional footer data can be added on the Data field. // // Overall, the following JSONToken and JSONFooter is generated for each // token, // // JSONToken:{ // "iss": <Key.ID>, +// "sub": <Subject parameter>, +// "aud": <Audience parameter> // "exp": <time.Now() + TTL>, // "iat": <time.Now()>, +// "nbf": <time.Now()>, // "data": <base64.StdEncoding.EncodeToString(userData)>, // } // JSONFooter:{ @@ -46,6 +55,38 @@ // "data": {} // } // +// On the receiver side, they will have list of registered peers Key (include +// ID, public Key, and list of allowed subject). +// +// PublicMode:{ +// peers: map[Key.ID]Key{ +// Public: <ed25519.PublicKey>, +// AllowedSubjects: map[string]struct{}{ +// "/api/x": struct{}{}, +// "/api/y:read": struct{}{}, +// "/api/z:write": struct{}{}, +// ... +// }, +// }, +// } +// +// Step for receiver to process the token, the Unpack() method, +// +// * Decode the token footer +// * Get the registered public key based on "kid" value in token footer +// ** If no peers key exist matched with "kid" value, reject the token +// * Verify the token using the peer public key +// ** If verification failed, reject the token +// * Validate the token +// ** The Issuer must equal to peer ID +// ** The Audience must equal to receiver ID +// ** If the peer AllowedSubjects is not empty, the Subject must be in +// one of them +// ** The current time must be after IssuedAt +// ** The current time must be after NotBefore +// ** The current time must be before ExpiredAt +// ** If one of the above condition is not passed, it will return an error. +// // References // // [1] https://github.com/paragonie/paseto/blob/master/docs/RFC/draft-paragon-paseto-rfc-01.txt diff --git a/lib/paseto/public_mode.go b/lib/paseto/public_mode.go index 41c4ef34..77244146 100644 --- a/lib/paseto/public_mode.go +++ b/lib/paseto/public_mode.go @@ -5,7 +5,6 @@ package paseto import ( - "crypto/ed25519" "encoding/base64" "encoding/json" "fmt" @@ -13,42 +12,72 @@ import ( "time" ) +// +// DefaultTTL define the time-to-live of each token, by setting ExpiredAt to +// current time + DefaultTTL. +// If you want longer token, increase this value before using Pack(). +// var DefaultTTL = 60 * time.Second // // PublicMode implement the PASETO public mode to signing and verifying data // using private key and one or more shared public keys. +// The PublicMode contains list of peer public keys for verifying the incoming +// token. // type PublicMode struct { our Key - peers map[string]ed25519.PublicKey + peers *keys } // // NewPublicMode create new PublicMode with our private key for signing -// outgoing token and list of peer public keys for verifying the incoming -// token. +// outgoing token. // -func NewPublicMode(our Key, peers map[string]ed25519.PublicKey) (auth *PublicMode) { +func NewPublicMode(our Key) (auth *PublicMode) { auth = &PublicMode{ our: our, - peers: peers, + peers: newKeys(), } - return auth } // +// AddPeer add a key to list of known peers for verifying incoming token. +// The Key.Public +// +func (auth *PublicMode) AddPeer(k Key) (err error) { + if len(k.ID) == 0 { + return fmt.Errorf("empty key ID") + } + if len(k.Public) == 0 { + return fmt.Errorf("empty public key") + } + auth.peers.upsert(k) + return nil +} + +// +// RemovePeer remove peer's key from list. +// +func (auth *PublicMode) RemovePeer(id string) { + auth.peers.delete(id) +} + +// // Pack the data into token. // -func (auth *PublicMode) Pack(data []byte, addFooter map[string]interface{}) ( +func (auth *PublicMode) Pack(audience, subject string, data []byte, footer map[string]interface{}) ( token string, err error, ) { now := time.Now() expiredAt := now.Add(DefaultTTL) jsonToken := JSONToken{ - Issuer: auth.our.id, + Issuer: auth.our.ID, + Subject: subject, + Audience: audience, IssuedAt: &now, + NotBefore: &now, ExpiredAt: &expiredAt, Data: base64.StdEncoding.EncodeToString(data), } @@ -59,22 +88,22 @@ func (auth *PublicMode) Pack(data []byte, addFooter map[string]interface{}) ( } jsonFooter := JSONFooter{ - KID: auth.our.id, - Data: addFooter, + KID: auth.our.ID, + Data: footer, } - footer, err := json.Marshal(&jsonFooter) + rawfooter, err := json.Marshal(&jsonFooter) if err != nil { return "", err } - return Sign(auth.our.private, msg, footer) + return Sign(auth.our.Private, msg, rawfooter) } // // Unpack the token to get the JSONToken and the data. // -func (auth *PublicMode) Unpack(token string) (data []byte, addFooter map[string]interface{}, err error) { +func (auth *PublicMode) Unpack(token string) (data []byte, footer map[string]interface{}, err error) { pieces := strings.Split(token, ".") if len(pieces) != 4 { return nil, nil, fmt.Errorf("invalid token format") @@ -86,17 +115,17 @@ func (auth *PublicMode) Unpack(token string) (data []byte, addFooter map[string] return nil, nil, fmt.Errorf("expecting public mode, got " + pieces[1]) } - footer, err := base64.RawURLEncoding.DecodeString(pieces[3]) + rawfooter, err := base64.RawURLEncoding.DecodeString(pieces[3]) if err != nil { return nil, nil, err } jsonFooter := &JSONFooter{} - err = json.Unmarshal(footer, jsonFooter) + err = json.Unmarshal(rawfooter, jsonFooter) if err != nil { return nil, nil, err } - peerKey, ok := auth.peers[jsonFooter.KID] + peerKey, ok := auth.peers.get(jsonFooter.KID) if !ok { return nil, nil, fmt.Errorf("unknown peer key ID %s", jsonFooter.KID) } @@ -106,7 +135,7 @@ func (auth *PublicMode) Unpack(token string) (data []byte, addFooter map[string] return nil, nil, err } - msg, err := Verify(peerKey, msgSig, footer) + msg, err := Verify(peerKey.Public, msgSig, rawfooter) if err != nil { return nil, nil, err } @@ -117,7 +146,7 @@ func (auth *PublicMode) Unpack(token string) (data []byte, addFooter map[string] return nil, nil, err } - err = jtoken.Validate() + err = jtoken.Validate(auth.our.ID, peerKey) if err != nil { return nil, nil, err } |
