diff --git a/.circleci/config.yml b/.circleci/config.yml index c2f13d4..659ae4d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -41,17 +41,6 @@ jobs: docker: - image: circleci/golang:1.9 - "1.8": - <<: *test - docker: - - image: circleci/golang:1.8 - - "1.7": - <<: *test - docker: - - image: circleci/golang:1.7 - - workflows: version: 2 build: @@ -61,5 +50,3 @@ workflows: - "1.11" - "1.10" - "1.9" - - "1.8" - - "1.7" diff --git a/README.md b/README.md index f416daa..2054941 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,24 @@ registered first using gob.Register(). For basic types this is not needed; it works out of the box. An optional JSON encoder that uses `encoding/json` is available for types compatible with JSON. +### Compact Encoding + +Original encoding adds a lot of unnecessary overhead for encoded value. +Therefore new encoding is added to reduce length of cookie. To simplify +migration, same SecureCookie instance may decode both original and compact +encodings, but generates those you choose to. By default original encoding +is used therefore you may safely update this library without code change. + +```go +var s = securecookie.New(hashKey, blockKey) +s.Compact(true) // enable generation of compact encoding. +s.Compact(false) // disable generation of compact encoding. It is default. +``` + +Not that algorithms are fixed with compact encoding: ChaCha20 is used for +stream cipher and HMAC-SHA256 is used as a MAC and key expansion (to meet ChaCha20 +requirements for key length). + ### Key Rotation Rotating keys is an important part of any security strategy. The `EncodeMulti` and `DecodeMulti` functions allow for multiple keys to be rotated in and out. diff --git a/compact.go b/compact.go new file mode 100644 index 0000000..1511f6e --- /dev/null +++ b/compact.go @@ -0,0 +1,161 @@ +package securecookie + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/binary" + "hash" + "sync" + + "golang.org/x/crypto/chacha20" +) + +const ( + nameMaxLen = 127 + keyLen = 32 + macLen = 16 + headerLen = 8 + macHeaderLen = macLen + headerLen + version = 1 +) + +func (s *SecureCookie) prepareCompact() { + bl := hmac.New(sha256.New, s.hashKey) + _, _ = bl.Write(s.blockKey) + copy(s.compactBlockKey[:], bl.Sum(nil)) + + s.macPool = &sync.Pool{ + New: func() interface{} { + hsh := hmac.New(sha256.New, s.hashKey) + return &macbuf{Hash: hsh} + }, + } +} + +func (s *SecureCookie) encodeCompact(name string, serialized []byte) (string, error) { + // Check length + encodedLen := base64.URLEncoding.EncodedLen(len(serialized) + macLen + headerLen) + if s.maxLength != 0 && encodedLen > s.maxLength { + return "", errEncodedValueTooLong + } + + // form message + r := make([]byte, headerLen+macLen+len(serialized)) + macHeader, body := r[:macHeaderLen], r[macHeaderLen:] + copy(body, serialized) + + header, mac := macHeader[:headerLen], macHeader[headerLen:] + composeHeader(version, timestampNano(), header) + + // Mac + s.compactMac(header, name, body, mac) + + // Encrypt (if needed) + s.compactXorStream(macHeader, body) + + // Encode + return base64.RawURLEncoding.EncodeToString(r), nil +} + +func (s *SecureCookie) decodeCompact(name string, encoded string, dest interface{}) error { + decoded, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return cookieError{cause: err, typ: decodeError, msg: "base64 decode failed"} + } + + if len(encoded) < macHeaderLen { + return errValueToDecodeTooShort + } + + macHeader, body := decoded[:macHeaderLen], decoded[macHeaderLen:] + header, mac := macHeader[:headerLen], macHeader[headerLen:] + + // Decompose + v, ts := decomposeHeader(header) + if v != version { + // there is only version currently + return errVersionDoesntMatch + } + + // Check time + now := timestampNano() + if s.maxAge > 0 && ts+secs2nano(s.maxAge) < now { + return errTimestampExpired + } + if s.minAge > 0 && ts+secs2nano(s.minAge) > now { + return errTimestampExpired + } + + // Decrypt (if need) + s.compactXorStream(macHeader, body) + + // Check MAC + var macCheck [macLen]byte + s.compactMac(header, name, body, macCheck[:]) + if subtle.ConstantTimeCompare(mac, macCheck[:]) == 0 { + return ErrMacInvalid + } + + // Deserialize + if err := s.sz.Deserialize(body, dest); err != nil { + return cookieError{cause: err, typ: decodeError} + } + + return nil +} + +type macbuf struct { + hash.Hash + nameLen [4]byte + sum [32]byte +} + +func (m *macbuf) Reset() { + m.Hash.Reset() + m.sum = [32]byte{} +} + +func (s *SecureCookie) compactMac(header []byte, name string, body, mac []byte) { + enc := s.macPool.Get().(*macbuf) + + binary.BigEndian.PutUint32(enc.nameLen[:], uint32(len(name))) + _, _ = enc.Write(header) + _, _ = enc.Write(enc.nameLen[:]) + _, _ = enc.Write([]byte(name)) + _, _ = enc.Write(body) + + copy(mac, enc.Sum(enc.sum[:0])) + + enc.Reset() + s.macPool.Put(enc) +} + +func (s *SecureCookie) compactXorStream(nonce, body []byte) { + if len(s.blockKey) == 0 { // no blockKey - no encryption + return + } + stream, err := chacha20.NewUnauthenticatedCipher(s.compactBlockKey[:], nonce) + if err != nil { + panic("stream initialization failed") + } + stream.XORKeyStream(body, body) +} + +func composeHeader(v byte, t int64, header []byte) { + ut := uint64(t) >> 8 // clear highest octet for version + binary.BigEndian.PutUint64(header, ut) + header[0] = v +} + +func decomposeHeader(header []byte) (v byte, t int64) { + v = header[0] + ut := binary.BigEndian.Uint64(header) + t = int64(ut << 8) + return +} + +func secs2nano(t int64) int64 { + return t * 1000000000 +} diff --git a/go.mod b/go.mod index db69e44..60b7f26 100644 --- a/go.mod +++ b/go.mod @@ -1 +1,3 @@ module github.com/gorilla/securecookie + +require golang.org/x/crypto v0.0.0-20200707235045-ab33eee955e0 diff --git a/securecookie.go b/securecookie.go index b718ce9..e6d309a 100644 --- a/securecookie.go +++ b/securecookie.go @@ -20,6 +20,7 @@ import ( "io" "strconv" "strings" + "sync" "time" ) @@ -89,20 +90,23 @@ func (e cookieError) Error() string { } var ( - errGeneratingIV = cookieError{typ: internalError, msg: "failed to generate random iv"} + errGeneratingIV = cookieError{typ: internalError, msg: "failed to generate random iv"} + errGeneratingMAC = cookieError{typ: internalError, msg: "failed to generate mac"} errNoCodecs = cookieError{typ: usageError, msg: "no codecs provided"} errHashKeyNotSet = cookieError{typ: usageError, msg: "hash key is not set"} errBlockKeyNotSet = cookieError{typ: usageError, msg: "block key is not set"} errEncodedValueTooLong = cookieError{typ: usageError, msg: "the value is too long"} - errValueToDecodeTooLong = cookieError{typ: decodeError, msg: "the value is too long"} - errTimestampInvalid = cookieError{typ: decodeError, msg: "invalid timestamp"} - errTimestampTooNew = cookieError{typ: decodeError, msg: "timestamp is too new"} - errTimestampExpired = cookieError{typ: decodeError, msg: "expired timestamp"} - errDecryptionFailed = cookieError{typ: decodeError, msg: "the value could not be decrypted"} - errValueNotByte = cookieError{typ: decodeError, msg: "value not a []byte."} - errValueNotBytePtr = cookieError{typ: decodeError, msg: "value not a pointer to []byte."} + errValueToDecodeTooLong = cookieError{typ: decodeError, msg: "the value is too long"} + errTimestampInvalid = cookieError{typ: decodeError, msg: "invalid timestamp"} + errTimestampTooNew = cookieError{typ: decodeError, msg: "timestamp is too new"} + errTimestampExpired = cookieError{typ: decodeError, msg: "expired timestamp"} + errDecryptionFailed = cookieError{typ: decodeError, msg: "the value could not be decrypted"} + errValueNotByte = cookieError{typ: decodeError, msg: "value not a []byte."} + errValueNotBytePtr = cookieError{typ: decodeError, msg: "value not a pointer to []byte."} + errValueToDecodeTooShort = cookieError{typ: decodeError, msg: "the value is too short"} + errVersionDoesntMatch = cookieError{typ: decodeError, msg: "value version unknown"} // ErrMacInvalid indicates that cookie decoding failed because the HMAC // could not be extracted and verified. Direct use of this error @@ -147,6 +151,7 @@ func New(hashKey, blockKey []byte) *SecureCookie { if blockKey != nil { s.BlockFunc(aes.NewCipher) } + s.prepareCompact() return s } @@ -162,9 +167,10 @@ type SecureCookie struct { minAge int64 err error sz Serializer - // For testing purposes, the function that returns the current timestamp. - // If not set, it will use time.Now().UTC().Unix(). - timeFunc func() int64 + + compactBlockKey [keyLen]byte + macPool *sync.Pool + genCompact bool } // Serializer provides an interface for providing custom serializers for cookie @@ -244,6 +250,16 @@ func (s *SecureCookie) SetSerializer(sz Serializer) *SecureCookie { return s } +// Compact sets generation mode. +// +// If set to true, then compact encoding will be used for cookie. +// Note, it will use Blake2b as a hash function and ChaCha20 as a cipher +// exclusively. And hash key and block key will be derived with Blake2b. +func (s *SecureCookie) Compact(c bool) *SecureCookie { + s.genCompact = c + return s +} + // Encode encodes a cookie value. // // It serializes, optionally encrypts, signs with a message authentication code, @@ -270,6 +286,11 @@ func (s *SecureCookie) Encode(name string, value interface{}) (string, error) { if b, err = s.sz.Serialize(value); err != nil { return "", cookieError{cause: err, typ: usageError} } + + if s.genCompact { + return s.encodeCompact(name, b) + } + // 2. Encrypt (optional). if s.block != nil { if b, err = encrypt(s.block, b); err != nil { @@ -278,7 +299,7 @@ func (s *SecureCookie) Encode(name string, value interface{}) (string, error) { } b = encode(b) // 3. Create MAC for "name|date|value". Extra pipe to be used later. - b = []byte(fmt.Sprintf("%s|%d|%s|", name, s.timestamp(), b)) + b = []byte(fmt.Sprintf("%s|%d|%s|", name, timestamp(), b)) mac := createMac(hmac.New(s.hashFunc, s.hashKey), b[:len(b)-1]) // Append mac, remove name. b = append(b, mac...)[len(name)+1:] @@ -312,6 +333,9 @@ func (s *SecureCookie) Decode(name, value string, dst interface{}) error { if s.maxLength != 0 && len(value) > s.maxLength { return errValueToDecodeTooLong } + if len(value) > 0 && value[0] == 'A' { // first byte of decoded value is less than 0x04 + return s.decodeCompact(name, value, dst) + } // 2. Decode from base64. b, err := decode([]byte(value)) if err != nil { @@ -332,7 +356,7 @@ func (s *SecureCookie) Decode(name, value string, dst interface{}) error { if t1, err = strconv.ParseInt(string(parts[0]), 10, 64); err != nil { return errTimestampInvalid } - t2 := s.timestamp() + t2 := timestamp() if s.minAge != 0 && t1 > t2-s.minAge { return errTimestampTooNew } @@ -357,15 +381,20 @@ func (s *SecureCookie) Decode(name, value string, dst interface{}) error { return nil } +var faketsnano int64 + +func timestampNano() int64 { + if faketsnano != 0 { + return faketsnano + } + return time.Now().UnixNano() +} + // timestamp returns the current timestamp, in seconds. // -// For testing purposes, the function that generates the timestamp can be -// overridden. If not set, it will return time.Now().UTC().Unix(). -func (s *SecureCookie) timestamp() int64 { - if s.timeFunc == nil { - return time.Now().UTC().Unix() - } - return s.timeFunc() +// For For testing purposes, one could override faketsnano variable. +func timestamp() int64 { + return timestampNano() / 1000000000 } // Authentication ------------------------------------------------------------- diff --git a/securecookie_test.go b/securecookie_test.go index c32ff33..c703ffb 100644 --- a/securecookie_test.go +++ b/securecookie_test.go @@ -10,6 +10,7 @@ import ( "crypto/sha256" "encoding/base64" "fmt" + "math/rand" "reflect" "strings" "testing" @@ -35,7 +36,13 @@ func TestSecureCookie(t *testing.T) { "baz": 128, } + rng := rand.New(rand.NewSource(1)) + for i := 0; i < 50; i++ { + t.Log("i=", i) + s1.Compact(i&1 != 0) + s2.Compact(i&2 != 0) + // Running this multiple times to check if any special character // breaks encoding/decoding. encoded, err1 := s1.Encode("sid", value) @@ -71,6 +78,20 @@ func TestSecureCookie(t *testing.T) { if err4.IsInternal() { t.Fatalf("Expected IsInternal() == false, got: %#v", err4) } + + // check compatibility + s1.Compact(!s1.genCompact) + dst3 := make(map[string]interface{}) + err5 := s1.Decode("sid", encoded, &dst3) + if err5 != nil { + t.Fatalf("%v: %v", err5, encoded) + } + if !reflect.DeepEqual(dst3, value) { + t.Fatalf("Expected %v, got %v.", value, dst3) + } + + value["foo"] = "bar" + string([]rune{rune(rng.Intn(1024) + 20)}) + value["bas"] = rng.Intn(1000000) } } @@ -306,3 +327,49 @@ func TestCustomType(t *testing.T) { t.Fatalf("Expected %#v, got %#v", src, dst) } } + +const N = 250 + +func benchmarkEncode(b *testing.B, compact bool) { + hk := make([]byte, 32) + bk := make([]byte, 32) + buf := make([]byte, N) + rand.Read(hk) + rand.Read(bk) + rand.Read(buf) + sec := New(hk, bk) + sec.SetSerializer(NopEncoder{}) + sec.Compact(compact) + b.ResetTimer() + for i := 0; i < b.N; i++ { + v := buf[:rand.Intn(N-N/4)+N/4] + _, _ = sec.Encode("session", v) + } +} + +func benchmarkDecode(b *testing.B, compact bool) { + hk := make([]byte, 32) + bk := make([]byte, 32) + buf := make([]byte, N) + rand.Read(hk) + rand.Read(bk) + rand.Read(buf) + sec := New(hk, bk) + sec.SetSerializer(NopEncoder{}) + sec.Compact(compact) + vals := make([]string, 128) + for i := range vals { + v := buf[:rand.Intn(N-N/4)+N/4] + vals[i], _ = sec.Encode("session", v) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + var v []byte + _ = sec.Decode("session", vals[i&127], &v) + } +} + +func BenchmarkLegacyEncode(b *testing.B) { benchmarkEncode(b, false) } +func BenchmarkCompactEncode(b *testing.B) { benchmarkEncode(b, true) } +func BenchmarkLegacyDecode(b *testing.B) { benchmarkDecode(b, false) } +func BenchmarkCompactDecode(b *testing.B) { benchmarkDecode(b, true) }