Skip to content

Add compact encoding (v2) #73

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -61,5 +50,3 @@ workflows:
- "1.11"
- "1.10"
- "1.9"
- "1.8"
- "1.7"
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
161 changes: 161 additions & 0 deletions compact.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
module github.com/gorilla/securecookie

require golang.org/x/crypto v0.0.0-20200707235045-ab33eee955e0
69 changes: 49 additions & 20 deletions securecookie.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"io"
"strconv"
"strings"
"sync"
"time"
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -147,6 +151,7 @@ func New(hashKey, blockKey []byte) *SecureCookie {
if blockKey != nil {
s.BlockFunc(aes.NewCipher)
}
s.prepareCompact()
return s
}

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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:]
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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 -------------------------------------------------------------
Expand Down
Loading