From 08bf55d3edb11bdeca2b0541fd1b7c061fbcc452 Mon Sep 17 00:00:00 2001 From: Jordan Hiltunen Date: Mon, 16 Mar 2026 13:53:07 -0400 Subject: [PATCH 1/5] :truck: goRailsYourself/: inline from https://github.com/mattetti/goRailsYourself --- goRailsYourself/.github/workflows/go.yml | 28 ++ goRailsYourself/.gitignore | 27 ++ goRailsYourself/.idea/.gitignore | 10 + goRailsYourself/.travis.yml | 10 + goRailsYourself/LICENSE | 21 + goRailsYourself/README.md | 27 ++ goRailsYourself/crypto/LICENSE | 21 + goRailsYourself/crypto/aes_cbc.go | 108 ++++++ goRailsYourself/crypto/aes_gcm.go | 109 ++++++ goRailsYourself/crypto/crypto.go | 21 + goRailsYourself/crypto/doc.go | 150 ++++++++ goRailsYourself/crypto/json_msg_serializer.go | 20 + .../crypto/json_msg_serializer_test.go | 44 +++ goRailsYourself/crypto/key_generator.go | 38 ++ goRailsYourself/crypto/key_generator_test.go | 42 ++ goRailsYourself/crypto/message_encryptor.go | 142 +++++++ .../crypto/message_encryptor_test.go | 358 ++++++++++++++++++ goRailsYourself/crypto/message_verifier.go | 132 +++++++ .../crypto/message_verifier_test.go | 254 +++++++++++++ goRailsYourself/crypto/null_msg_serializer.go | 24 ++ .../crypto/null_msg_serializer_test.go | 43 +++ goRailsYourself/crypto/pkcs7_padding.go | 41 ++ goRailsYourself/crypto/pkcs7_padding_test.go | 63 +++ goRailsYourself/crypto/xml_msg_serializer.go | 20 + .../crypto/xml_msg_serializer_test.go | 44 +++ goRailsYourself/go.mod | 9 + goRailsYourself/go.sum | 12 + goRailsYourself/inflector/inflector.go | 40 ++ goRailsYourself/inflector/inflector_test.go | 66 ++++ 29 files changed, 1924 insertions(+) create mode 100644 goRailsYourself/.github/workflows/go.yml create mode 100644 goRailsYourself/.gitignore create mode 100644 goRailsYourself/.idea/.gitignore create mode 100644 goRailsYourself/.travis.yml create mode 100644 goRailsYourself/LICENSE create mode 100644 goRailsYourself/README.md create mode 100644 goRailsYourself/crypto/LICENSE create mode 100644 goRailsYourself/crypto/aes_cbc.go create mode 100644 goRailsYourself/crypto/aes_gcm.go create mode 100644 goRailsYourself/crypto/crypto.go create mode 100644 goRailsYourself/crypto/doc.go create mode 100644 goRailsYourself/crypto/json_msg_serializer.go create mode 100644 goRailsYourself/crypto/json_msg_serializer_test.go create mode 100644 goRailsYourself/crypto/key_generator.go create mode 100644 goRailsYourself/crypto/key_generator_test.go create mode 100644 goRailsYourself/crypto/message_encryptor.go create mode 100644 goRailsYourself/crypto/message_encryptor_test.go create mode 100644 goRailsYourself/crypto/message_verifier.go create mode 100644 goRailsYourself/crypto/message_verifier_test.go create mode 100644 goRailsYourself/crypto/null_msg_serializer.go create mode 100644 goRailsYourself/crypto/null_msg_serializer_test.go create mode 100644 goRailsYourself/crypto/pkcs7_padding.go create mode 100644 goRailsYourself/crypto/pkcs7_padding_test.go create mode 100644 goRailsYourself/crypto/xml_msg_serializer.go create mode 100644 goRailsYourself/crypto/xml_msg_serializer_test.go create mode 100644 goRailsYourself/go.mod create mode 100644 goRailsYourself/go.sum create mode 100644 goRailsYourself/inflector/inflector.go create mode 100644 goRailsYourself/inflector/inflector_test.go diff --git a/goRailsYourself/.github/workflows/go.yml b/goRailsYourself/.github/workflows/go.yml new file mode 100644 index 0000000..cd63a78 --- /dev/null +++ b/goRailsYourself/.github/workflows/go.yml @@ -0,0 +1,28 @@ +name: Go + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + + build: + name: Build + runs-on: ubuntu-latest + steps: + + - name: Set up Go 1.x + uses: actions/setup-go@v2 + with: + go-version: ^1.13 + + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... diff --git a/goRailsYourself/.gitignore b/goRailsYourself/.gitignore new file mode 100644 index 0000000..b7c6320 --- /dev/null +++ b/goRailsYourself/.gitignore @@ -0,0 +1,27 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.test +*.exe + +# vim temp files +.*.swp +.*.swo diff --git a/goRailsYourself/.idea/.gitignore b/goRailsYourself/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/goRailsYourself/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/goRailsYourself/.travis.yml b/goRailsYourself/.travis.yml new file mode 100644 index 0000000..f017260 --- /dev/null +++ b/goRailsYourself/.travis.yml @@ -0,0 +1,10 @@ +language: go +go: + - 1.1 + - release + - tip + +install: + - go get -u github.com/franela/goblin + - go get -u golang.org/x/crypto/pbkdf2 + - go get -u github.com/fiam/gounidecode/unidecode diff --git a/goRailsYourself/LICENSE b/goRailsYourself/LICENSE new file mode 100644 index 0000000..11fed56 --- /dev/null +++ b/goRailsYourself/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Matt Aimonetti + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/goRailsYourself/README.md b/goRailsYourself/README.md new file mode 100644 index 0000000..b78fbd7 --- /dev/null +++ b/goRailsYourself/README.md @@ -0,0 +1,27 @@ +goRailsYourself +=============== + +[![GoDoc](http://godoc.org/github.com/mattetti/goRailsYourself?status.png)](https://pkg.go.dev/github.com/mattetti/goRailsYourself) + + +A suite of packages useful when you have to deal with Go and Rails apps +or when migrating from Ruby to Go. + +The crypto package allows for shared authentication cookie support with Rails, included version 5.2+. + + +See the [documentation](http://godoc.org/github.com/mattetti/goRailsYourself) and/or the test suite for more examples. + +## Dependencies: + +The inflector package relies on: + [unidecode](http://godoc.org/github.com/fiam/gounidecode/unidecode) to handle the transliteration. + +The crypto package relies on: + [pbkdf2](http://golang.org/x/crypto/pbkdf2) to handle the +generation of derived keys. + +The test suite uses +[Goblin](http://tech.gilt.com/post/64409561192/goblin-a-minimal-and-beautiful-testing-framework-for) + + diff --git a/goRailsYourself/crypto/LICENSE b/goRailsYourself/crypto/LICENSE new file mode 100644 index 0000000..11fed56 --- /dev/null +++ b/goRailsYourself/crypto/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Matt Aimonetti + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/goRailsYourself/crypto/aes_cbc.go b/goRailsYourself/crypto/aes_cbc.go new file mode 100644 index 0000000..3c40f90 --- /dev/null +++ b/goRailsYourself/crypto/aes_cbc.go @@ -0,0 +1,108 @@ +package crypto + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "io" + "strings" +) + +func (crypt *MessageEncryptor) aesCbcEncrypt(value interface{}) (string, error) { + // TODO: check the crypt is properly initiated + k := crypt.Key + // The longest accepted key is 32 byte long, + // instead of rejecting a long key, we truncate it. + // This is how openssl in Ruby works. + if len(k) > 32 { + k = crypt.Key[:32] + } + block, err := aes.NewCipher(k) + if err != nil { + return "", err + } + + // Set a default serializer if not already set + if crypt.Serializer == nil { + crypt.Serializer = JsonMsgSerializer{} + } + splaintext, err := crypt.Serializer.Serialize(value) + if err != nil { + return "", err + } + plaintext := []byte(splaintext) + + // CBC mode works on blocks so plaintexts may need to be padded to the + // next whole block. See + // http://tools.ietf.org/html/rfc5652#section-6.3 + plaintext = PKCS7Pad(plaintext) + + // The IV needs to be unique, but not secure, it is included in the + // cypher text. + iv := make([]byte, aes.BlockSize) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return "", err + } + + // generate the cipher text + mode := cipher.NewCBCEncrypter(block, iv) + ciphertext := make([]byte, len(plaintext)) + mode.CryptBlocks(ciphertext, plaintext) + + // base64 the cipher text + the iv and join by "--" + output := base64.StdEncoding.EncodeToString(ciphertext) + "--" + base64.StdEncoding.EncodeToString(iv) + return output, nil +} + +func (crypt *MessageEncryptor) aesCbcDecrypt(encryptedMsg string, target interface{}) error { + k := crypt.Key + // The longest accepted key is 32 byte long, + // instead of rejecting a long key, we truncate it. + // This is how openssl in Ruby works. + if len(k) > 32 { + k = crypt.Key[:32] + } + + block, err := aes.NewCipher(k) + if err != nil { + return err + } + + // split the msg and decode each part + splitMsg := strings.Split(encryptedMsg, "--") + if len(splitMsg) != 2 { + return errors.New("bad data (--)") + } + + ciphertext, err := base64.StdEncoding.DecodeString(splitMsg[0]) + if err != nil { + return err + } + iv, err := base64.StdEncoding.DecodeString(splitMsg[1]) + if err != nil { + return err + } + + if len(ciphertext) < aes.BlockSize { + return errors.New("bad data, ciphertext too short") + } + if len(ciphertext)%aes.BlockSize != 0 { + return errors.New("bad data, ciphertext is not a multiple of the block size") + } + + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(ciphertext, ciphertext) + unPaddedCiphertext := PKCS7Unpad(ciphertext) + + // In some cases, Rails sends us messages padded with 0x10 (while this package only pads with 0x01-0x0f). + // For now, we handle this case here when the Serializer is JSON (so we know that 0x10 is actually a padding + // and not valid data - because this is an invalid json character). + if _, ok := crypt.Serializer.(JsonMsgSerializer); ok { + unPaddedCiphertext = bytes.TrimRight(unPaddedCiphertext, "\x10") + } + + return crypt.Serializer.Unserialize(string(unPaddedCiphertext), target) +} diff --git a/goRailsYourself/crypto/aes_gcm.go b/goRailsYourself/crypto/aes_gcm.go new file mode 100644 index 0000000..26f74cf --- /dev/null +++ b/goRailsYourself/crypto/aes_gcm.go @@ -0,0 +1,109 @@ +package crypto + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "io" +) + +func (crypt *MessageEncryptor) aesGCMEncrypt(value interface{}) (string, error) { + // TODO: check the crypt is properly initiated + k := crypt.Key + // The longest accepted key is 32 byte long, + // instead of rejecting a long key, we truncate it. + // This is how openssl in Ruby works. + if len(k) > 32 { + k = crypt.Key[:32] + } + block, err := aes.NewCipher(k) + if err != nil { + return "", err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + // Set a default serializer if not already set + if crypt.Serializer == nil { + crypt.Serializer = JsonMsgSerializer{} + } + splaintext, err := crypt.Serializer.Serialize(value) + if err != nil { + return "", err + } + plaintext := []byte(splaintext) + + iv := make([]byte, aesgcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return "", err + } + + ciphertext := aesgcm.Seal(nil, iv, plaintext, nil) + + // Rails stores the GCM auth tag separately from the encrypted data, + // unlike the cipher package, so a little munging is required. + // Luckily aesgcm.Overhead() is the tag size (which is 16). + tagStart := len(ciphertext) - aesgcm.Overhead() + tag := ciphertext[tagStart:] + enc := ciphertext[:tagStart] + + vectors := [][]byte{enc, iv, tag} + for i, vec := range vectors { + dst := make([]byte, base64.StdEncoding.EncodedLen(len(vec))) + base64.StdEncoding.Encode(dst, vec) + vectors[i] = dst + } + + output := string(bytes.Join(vectors, []byte("--"))) + return output, nil +} + +func (crypt *MessageEncryptor) aesGCMDecrypt(encryptedMsg string, target interface{}) error { + k := crypt.Key + // The longest accepted key is 32 byte long, + // instead of rejecting a long key, we truncate it. + // This is how openssl in Ruby works. + if len(k) > 32 { + k = crypt.Key[:32] + } + + block, err := aes.NewCipher(k) + if err != nil { + return err + } + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return err + } + + vectors := bytes.SplitN([]byte(encryptedMsg), []byte("--"), 3) + if len(vectors) != 3 { + return fmt.Errorf("missing vectors, want 3, got %d", len(vectors)) + } + for i, vec := range vectors { + dst := make([]byte, base64.StdEncoding.DecodedLen(len(vec))) + n, err := base64.StdEncoding.Decode(dst, vec) + if err != nil { + return fmt.Errorf("bad base64 encoding") + } + vectors[i] = dst[:n] + } + + enc := vectors[0] + // Rails splits the auth tag into a separate vector, which is unnecessary really, but fine. + enc = append(enc, vectors[2]...) + nonce := vectors[1] + + plain, err := aesgcm.Open(nil, nonce, enc, nil) + if err != nil { + return err + } + + return crypt.Serializer.Unserialize(string(plain), target) +} diff --git a/goRailsYourself/crypto/crypto.go b/goRailsYourself/crypto/crypto.go new file mode 100644 index 0000000..88ce91d --- /dev/null +++ b/goRailsYourself/crypto/crypto.go @@ -0,0 +1,21 @@ +package crypto + +import ( + "crypto/rand" + "io" +) + +type MsgSerializer interface { + Serialize(v interface{}) (string, error) + Unserialize(data string, v interface{}) error +} + +// Generates a random key of the passed length. +// As a reminder, for AES keys of length 16, 24, or 32 bytes are expected for AES-128, AES-192, or AES-256. +func GenerateRandomKey(strength int) []byte { + k := make([]byte, strength) + if _, err := io.ReadFull(rand.Reader, k); err != nil { + return nil + } + return k +} diff --git a/goRailsYourself/crypto/doc.go b/goRailsYourself/crypto/doc.go new file mode 100644 index 0000000..b203f6f --- /dev/null +++ b/goRailsYourself/crypto/doc.go @@ -0,0 +1,150 @@ +// Copyright (c) 2013, Matt Aimonetti +// Copyright (c) 2021, James Tucker +// Use of this source code is governed by a MIT style +// license that can be found at https://opensource.org/licenses/MIT + +/* +Package crypto ports some of Ruby on Rails' crypto: + - version 4+: encrypted & signed messages (aes-cbc) + - version 5.2+: encrypted & authenticated messages (aes-256-gcm) + +Messages can be shared between a Ruby app and a Go app. That said, this +library is useful to anyone wanting to encrypt/sign/authenticate data. + +The initial focus of this package was to be able to easily share a Rails web session with a Go +app. Rails uses three classes provided by ActiveSupport (a library used +and maintained by the Rails team) + - MessageEncryptor + - MessageVerifier + - KeyGenerator + +to encrypt and sign sessions. In order to read/write a cookie session, +a Go app needs to be able to verify, decrypt/encrypt sign the session +data based on a shared secret. + +# Key components of this package + +The main components of this package are: + + - MessageEncryptor + - MessageVerifier + - KeyGenerator + +The difference between MessageVerifier and MessageEncryptor is that you +want to use MessageEncryptor when you don't want the content of the data +to be available to people accessing the data. In both cases, the data is +signed but if the message is just signed, the content can be read. + +Keygenerator is used to generate derived keys from a given secret. +If you want to generate a random key that isn't derived, look at +the GenerateRandomKey function. + +# Session serializer + +Since Rails 5.2, the default session serializer can be set to use JSON by +setting: + + Rails.application.config.action_dispatch.cookies_serializer = :json. + +In older Rails versions, it is necessary to make changes in order to move +away from the default session serializer (Marhsal). To be able to share the +session it needs to be serialized in a cross language format. +I wrote a patch to change Rails' default serializer to JSON: +https://gist.github.com/mattetti/7624413 +This package can use different serializers and you can also add your +own. This is useful if for instance you only have Go apps and choose to +use gob encoding or another encoding solution. Three serializers are +available JSON, XML and Null, the last serializer is basically a no-op +serializer used when the data doesn't need serialization and can be +transported as strings. + +# Rails session flow + +It's important to understand how Rails handles the crypto around the +session. +Here is a quick and high level of what Rails does (Ruby code): + + # Secret set in the app. + secret_key_base = "f7b5763636f4c1f3ff4bd444eacccca295d87b990cc104124017ad70550edcfd22b8e89465338254e0b608592a9aac29025440bfd9ce53579835ba06a86f85f9" + + # Rails 4+ / aes-cbc: + key_generator = ActiveSupport::CachingKeyGenerator.new(ActiveSupport::KeyGenerator.new(secret_key_base, iterations: 1000)) + secret = key_generator.generate_key("encrypted cookie") + sign_secret = key_generator.generate_key("signed encrypted cookie") + + encryptor = ActiveSupport::MessageEncryptor.new(secret, sign_secret, { serializer: JsonSessionSerializer } ) + # encrypt and sign the content of the session: + encrypted_message = encryptor.encrypt_and_sign({msg: "hello world"}) + # The encrypted and signed message is stored in the session cookie + # To decrypt and verify it: + # encryptor.decrypt_and_verify(encrypted_message) # => {:msg => "hello world"} + + # Rails 5.2+ / aes-256-gcm: + key_generator = ActiveSupport::CachingKeyGenerator.new(ActiveSupport::KeyGenerator.new(secret_key_base, iterations: 1000)) + secret = key_generator.generate_key("authenticated encrypted cookie", 32) + encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: 'aes-256-gcm', serializer: JSON) + # encrypt and authenticate the content of the session: + encrypted_message = encryptor.encrypt_and_sign({msg: "hello world"}) + # The encrypted and authenticated message is stored in the session cookie + # To authenticate and decrypt it: + # encryptor.decrypt_and_verify(encrypted_message) # => {:msg => "hello world"} + +The equivalent in Go is available in the documentation examples: http://godoc.org/github.com/mattetti/goRailsYourself/crypto#pkg-examples + +# Derived keys + +A few important things need to be mentioned. Rails uses a unique secret +that is used to derive different keys using a default salt. +To read more about this process, see http://en.wikipedia.org/wiki/PBKDF2 + +Rails defaults to 1000 iterations when generating the derived keys, when +generating the keys in Go, we need to match the iteration number to get +the same keys. Note also that if the salt is changed in the Rails app, +you need to update it in your Go code. + +With aes-cbc mode, there are two derived keys: one for encryption and one for +signing. With aes-256-gcm mode, there is only one key that is used for both +authentication and encryption. +These keys are derived from the same secret but are different to +increase security. The keys are also of two different length. +The message signature is done by default using HMAC/sha1 requiring +a key of 64 bytes. However, the message is encrypted by default using +AES-256 CBC requiring a key of 32 bytes. +Ruby's openssl library and this package automatically truncate longer +AES CBC keys so you can use two 64 byte keys. +This is exactly what Rails does, it generates two keys of same length (64 bytes) and +lets the OpenSSL wrapper truncate the key. I, however recommend you +generate keys of different length to avoid any confusion. +Here is an example for aes-cbc (Rails 4+): + + railsSecret := "f7b5763636f4c1f3ff4bd444eacccca295d87b990cc104124017ad70550edcfd22b8e89465338254e0b608592a9aac29025440bfd9ce53579835ba06a86f85f9" + encryptedCookieSalt := []byte("encrypted cookie") + encryptedSignedCookieSalt := []byte("signed encrypted cookie") + + kg := KeyGenerator{Secret: railsSecret} + secret := kg.CacheGenerate(encryptedCookieSalt, 32) + signSecret := kg.CacheGenerate(encryptedSignedCookieSalt, 64) + e := MessageEncryptor{Key: secret, SignKey: signSecret} + +Here is an example for aes-256-gcm (Rails 5.2+): + + railsSecret := "f7b5763636f4c1f3ff4bd444eacccca295d87b990cc104124017ad70550edcfd22b8e89465338254e0b608592a9aac29025440bfd9ce53579835ba06a86f85f9" + authenticatedCookieSalt := []byte("authenticated encrypted cookie") + + kg := KeyGenerator{Secret: railsSecret} + secret := kg.CacheGenerate(authenticated, 32) + e := MessageEncryptor{Key: secret, Cipher: "aes-256-gcm"} + +# Without Ruby + +The encryption used in Rails isn't specific to Ruby and this library can +be used to communicate with apps that aren't in Ruby. As a matter of +fact, you might want to use this library to encrypt/sign your web +sessions/cookies even if you just have one Go app. The Rails implementation +has been tested and vested by many people and is safe to use. + +It is recommended that new applications use the "aes-256-gcm" mode rather +than the "aes-cbc" mode, as the prior is a less error prone scheme and does +not rely on now out of favor cryptographic primitives. +*/ +package crypto diff --git a/goRailsYourself/crypto/json_msg_serializer.go b/goRailsYourself/crypto/json_msg_serializer.go new file mode 100644 index 0000000..7f927f5 --- /dev/null +++ b/goRailsYourself/crypto/json_msg_serializer.go @@ -0,0 +1,20 @@ +package crypto + +import ( + "encoding/json" +) + +type JsonMsgSerializer struct { +} + +func (s JsonMsgSerializer) Serialize(v interface{}) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil +} + +func (s JsonMsgSerializer) Unserialize(data string, v interface{}) error { + return json.Unmarshal([]byte(data), v) +} diff --git a/goRailsYourself/crypto/json_msg_serializer_test.go b/goRailsYourself/crypto/json_msg_serializer_test.go new file mode 100644 index 0000000..fec4254 --- /dev/null +++ b/goRailsYourself/crypto/json_msg_serializer_test.go @@ -0,0 +1,44 @@ +package crypto + +import ( + . "github.com/franela/goblin" + "testing" +) + +func TestJsonMsgSerializerSerializer(t *testing.T) { + g := Goblin(t) + serializer := JsonMsgSerializer{} + + g.Describe("a json serialized string", func() { + data := "this is a test" + output, err := serializer.Serialize(data) + g.Assert(err).Eql(err) + + g.It("can be deserialized", func() { + var o string + err := serializer.Unserialize(output, &o) + g.Assert(err).Eql(nil) + g.Assert(o).Eql(data) + }) + }) + + g.Describe("a json serialized struct", func() { + type Person struct { + Id int `json:"id"` + FirstName string `json:"name>first"` + LastName string `json:"name>last"` + Age int `json:"age"` + } + data := Person{Id: 13, FirstName: "John", LastName: "Doe", Age: 42} + output, err := serializer.Serialize(data) + g.Assert(err).Eql(err) + + g.It("can be deserialized", func() { + var o Person + err := serializer.Unserialize(output, &o) + g.Assert(err).Eql(nil) + g.Assert(o).Eql(data) + }) + }) + +} diff --git a/goRailsYourself/crypto/key_generator.go b/goRailsYourself/crypto/key_generator.go new file mode 100644 index 0000000..39c4add --- /dev/null +++ b/goRailsYourself/crypto/key_generator.go @@ -0,0 +1,38 @@ +package crypto + +import ( + "crypto/sha1" + "fmt" + "golang.org/x/crypto/pbkdf2" +) + +// KeyGenerator is a simple wrapper around a PBKDF2 implementation. +// It can be used to derive a number of keys for various purposes from a given secret. +// This lets applications have a single secure secret, but avoid reusing that +// key in multiple incompatible contexts. +type KeyGenerator struct { + Secret string + Iterations int + cache map[string][]byte +} + +// CacheGenerate() write through cache used to save generated keys. +func (g *KeyGenerator) CacheGenerate(salt []byte, keySize int) []byte { + key := fmt.Sprintf("%s%d", salt, keySize) + if g.cache == nil { + g.cache = map[string][]byte{} + } + if g.cache[key] == nil { + g.cache[key] = g.Generate(salt, keySize) + } + return g.cache[key] +} + +// Generates a derived key based on a salt. rails default key size is 64. +func (g *KeyGenerator) Generate(salt []byte, keySize int) []byte { + // set a default + if g.Iterations == 0 { + g.Iterations = 1000 // rails 4 default when setting the session. + } + return pbkdf2.Key([]byte(g.Secret), salt, g.Iterations, keySize, sha1.New) +} diff --git a/goRailsYourself/crypto/key_generator_test.go b/goRailsYourself/crypto/key_generator_test.go new file mode 100644 index 0000000..649ab49 --- /dev/null +++ b/goRailsYourself/crypto/key_generator_test.go @@ -0,0 +1,42 @@ +package crypto + +import ( + "fmt" + . "github.com/franela/goblin" + "testing" +) + +func TestKegenerator_Generate(t *testing.T) { + g := Goblin(t) + g.Describe("Generating a derived key from a secret", func() { + gen := KeyGenerator{Secret: "f7b5763636f4c1f3ff4bd444eacccca295d87b990cc104124017ad70550edcfd22b8e89465338254e0b608592a9aac29025440bfd9ce53579835ba06a86f85f9"} + g.It("always generates the same key for the same salt", func() { + salt := []byte("encrypted cookie") + keys := make([][]byte, 10) + for i := 0; i < 10; i++ { + keys[i] = gen.Generate(salt, 64) + } + first := keys[0] + for i := 1; i < 10; i++ { + g.Assert(keys[i]).Eql(first) + } + }) + }) + + g.Describe("Cache Generate a key", func() { + gen := KeyGenerator{Secret: "f7b5763636f4c1f3ff4bd444eacccca295d87b990cc104124017ad70550edcfd22b8e89465338254e0b608592a9aac29025440bfd9ce53579835ba06a86f85f9"} + g.It("caches the keys", func() { + key := func(s []byte) string { + return fmt.Sprintf("%s%d", s, 64) + } + salt1 := []byte("encrypted cookie") + salt2 := []byte("signed cookie") + _ = gen.CacheGenerate(salt1, 64) + g.Assert(gen.cache[key(salt1)] != nil).IsTrue() + g.Assert(gen.cache[key(salt2)] == nil).IsTrue() + _ = gen.CacheGenerate(salt2, 64) + g.Assert(gen.cache[key(salt2)] != nil).IsTrue() + }) + }) + +} diff --git a/goRailsYourself/crypto/message_encryptor.go b/goRailsYourself/crypto/message_encryptor.go new file mode 100644 index 0000000..0606ad9 --- /dev/null +++ b/goRailsYourself/crypto/message_encryptor.go @@ -0,0 +1,142 @@ +package crypto + +import ( + "crypto/sha1" + "errors" +) + +// MessageEncryptor is a simple way to encrypt values which get stored +// somewhere you don't trust. +// +// The cipher text and initialization vector are base64 encoded and returned +// to you. +// +// This can be used in situations similar to the MessageVerifier, but +// where you don't want users to be able to determine the value of the payload. +// +// Different kind of ciphers are supported: +// - aes-cbc - Rails' default until 5.2, requires a verifier +// - aes-256-gcm - Rails 5.2+ default, ignores verifier. +// +// Note: The old Rails default serializer, Marshal is neither safe or +// portable across langauges, use the JSON serializer. +type MessageEncryptor struct { + Key []byte + // optional property used to automatically set the + // verifier if not already set. + SignKey []byte + Cipher string + Verifier *MessageVerifier + Serializer MsgSerializer +} + +func (crypt *MessageEncryptor) withVerifier() bool { + switch crypt.Cipher { + case "aes-256-gcm": + return false + } + return true +} + +// EncryptAndSign performs encryption with authentication, or encryption +// followed by signing, depending on the selected cipher mode. message can be +// any serializable type (string, struct, map, etc). +// Note that even if you can just Encrypt() in most cases you shouldn't use it +// directly and instead use this method. +// For aes-cbc mode, encryption alone is neither signed or authenticated, and is +// subject to padding oracle attacks. +// Reference: http://www.limited-entropy.com/padding-oracle-attacks. +// The output string can be converted back using DecryptAndVerify() and is +// encoded using base64. +func (crypt *MessageEncryptor) EncryptAndSign(value interface{}) (string, error) { + if crypt == nil { + return "", errors.New("can't call EncryptAndSign on a nil *MessageEncryptor") + } + + if !crypt.withVerifier() { + return crypt.Encrypt(value) + } + + // Set a default verifier if a signature key was given instead of setting the verifier directly. + if crypt.Verifier == nil && crypt.SignKey != nil { + crypt.Verifier = &MessageVerifier{ + Secret: crypt.SignKey, + Hasher: sha1.New, + Serializer: NullMsgSerializer{}, + } + } + if crypt.Verifier == nil { + return "", errors.New("Verifier and/or signature key not set: ") + } + vvalid, err := crypt.Verifier.IsValid() + if !vvalid { + return "", errors.New("Verifier not properly set: " + err.Error()) + } + encryptedMsg, err := crypt.Encrypt(value) + if err != nil { + return "", err + } + return crypt.Verifier.Generate(encryptedMsg) +} + +// DecryptAndVerify decrypts and either authenticates or verifies the signature +// of a message, depending on the selected cipher mode. Messages need to be +// either signed or authenticated (GCM) on top of being encrypted in order to +// avoid padding attacks. Reference: http://www.limited-entropy.com/padding-oracle-attacks. +// The serializer will populate the pointer you are passing as second argument. +func (crypt *MessageEncryptor) DecryptAndVerify(msg string, target interface{}) error { + + if !crypt.withVerifier() { + return crypt.Decrypt(msg, target) + } + + // Set a default verifier if a signature key was given instead of setting the verifier directly. + if crypt.Verifier == nil && crypt.SignKey != nil { + crypt.Verifier = &MessageVerifier{ + Secret: crypt.SignKey, + Hasher: sha1.New, + Serializer: NullMsgSerializer{}, + } + } + var base64Msg string + // verify the data and get the encoded data out. + err := crypt.Verifier.Verify(msg, &base64Msg) + if err != nil { + return errors.New("Verification failed: " + err.Error()) + } + return crypt.Decrypt(base64Msg, target) +} + +// Encrypt encrypts a message using the set cipher and the secret. +// The returned value is a base 64 encoded string of the encrypted data + IV joined by "--". +// An encrypted message isn't safe unless it's signed! +func (crypt *MessageEncryptor) Encrypt(value interface{}) (string, error) { + switch crypt.Cipher { + case "aes-cbc": + return crypt.aesCbcEncrypt(value) + case "aes-256-gcm": + return crypt.aesGCMEncrypt(value) + case "": + // using a default if not set + return crypt.aesCbcEncrypt(value) + } + return "", errors.New("cipher not set or not supported") +} + +// Decrypt decrypts a message using the set cipher and the secret. +// The passed value is expected to be a base 64 encoded string of the encrypted data + IV joined by "--" +func (crypt *MessageEncryptor) Decrypt(value string, target interface{}) error { + if crypt.Serializer == nil { + crypt.Serializer = JsonMsgSerializer{} + } + switch crypt.Cipher { + case "aes-cbc": + return crypt.aesCbcDecrypt(value, target) + case "aes-256-gcm": + return crypt.aesGCMDecrypt(value, target) + case "": + // using a default if not set + return crypt.aesCbcDecrypt(value, target) + } + return errors.New("cipher not set or not supported") +} diff --git a/goRailsYourself/crypto/message_encryptor_test.go b/goRailsYourself/crypto/message_encryptor_test.go new file mode 100644 index 0000000..6abdd9c --- /dev/null +++ b/goRailsYourself/crypto/message_encryptor_test.go @@ -0,0 +1,358 @@ +package crypto + +import ( + "crypto/sha1" + "encoding/json" + "fmt" + "strings" + "testing" + + . "github.com/franela/goblin" +) + +func TestMessageEncryptorDefaultSettings(t *testing.T) { + g := Goblin(t) + + g.Describe("MessageEncryptor with default settings", func() { + k := GenerateRandomKey(32) + signKey := []byte("this is a secret!") + e := MessageEncryptor{Key: k, SignKey: signKey} + g.It("can round trip an encoded/unsigned string", func() { + msg, err := e.Encrypt("my secret data") + g.Assert(err).Eql(nil) + var newMsg string + err = e.Decrypt(msg, &newMsg) + g.Assert(err).Eql(nil) + g.Assert(newMsg).Eql("my secret data") + }) + g.It("can round trip an encoded/signed string", func() { + msg, err := e.EncryptAndSign("my secret data") + g.Assert(err).Eql(nil) + var newMsg string + err = e.DecryptAndVerify(msg, &newMsg) + g.Assert(err).Eql(nil) + g.Assert(newMsg).Eql("my secret data") + }) + + }) + +} + +func TestMessageEncryptor(t *testing.T) { + g := Goblin(t) + + g.Describe("MessageEncryptor properly setup using aes-256-gcm", func() { + newCrypt := func() MessageEncryptor { + return MessageEncryptor{Key: GenerateRandomKey(32), + Cipher: "aes-256-gcm", + Verifier: nil, + Serializer: JsonMsgSerializer{}, + } + } + + g.It("can encrypt/decrypt an unsigned string", func() { + e := newCrypt() + msg, err := e.Encrypt("my secret data") + g.Assert(err).Eql(nil) + splitMsg := strings.Split(msg, "--") + g.Assert(len(splitMsg)).Eql(3) + var newMsg string + err = e.Decrypt(msg, &newMsg) + g.Assert(err).Eql(nil) + g.Assert(newMsg).Eql("my secret data") + }) + + g.It("can encrypt/decrypt an unsigned struct", func() { + type Person struct { + Id int `json:"id"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Age int `json:"age"` + } + data := Person{Id: 12, FirstName: "John", LastName: "Doe", Age: 42} + e := newCrypt() + msg, err := e.Encrypt(data) + g.Assert(err).Eql(nil) + splitMsg := strings.Split(msg, "--") + g.Assert(len(splitMsg)).Eql(3) + var decryptedMsg Person + err = e.Decrypt(msg, &decryptedMsg) + g.Assert(err).Eql(nil) + g.Assert(decryptedMsg).Eql(data) + }) + + g.It("can round trip signed and encoded string", func() { + testData := "this is a test" + var e MessageEncryptor + for i := 0; i < 100; i++ { + e = newCrypt() + msg, err := e.EncryptAndSign(testData) + g.Assert(err).Eql(nil) + var output string + err = e.DecryptAndVerify(msg, &output) + g.Assert(err).Eql(nil) + if output != testData { + println(i, err.Error(), "FAILED", output, msg) + fmt.Printf("%#v\n", e) + } + g.Assert(output).Eql(testData) + } + }) + + g.It("can round trip signed and encoded struct", func() { + e := newCrypt() + testData := testStruct{Foo: "this is foo", Bar: 42} + msg, err := e.EncryptAndSign(testData) + g.Assert(err).Eql(nil) + var output testStruct + err = e.DecryptAndVerify(msg, &output) + g.Assert(err).Eql(nil) + g.Assert(output).Eql(testData) + }) + }) + + g.Describe("MessageEncryptor properly setup using aes cbc", func() { + newCrypt := func() MessageEncryptor { + return MessageEncryptor{Key: GenerateRandomKey(32), + Cipher: "aes-cbc", + Verifier: &MessageVerifier{ + Secret: []byte("signature secret!"), + Hasher: sha1.New, + Serializer: NullMsgSerializer{}, + }, + Serializer: JsonMsgSerializer{}, + } + } + + g.It("can encrypt/decrypt an unsigned string", func() { + e := newCrypt() + msg, err := e.Encrypt("my secret data") + g.Assert(err).Eql(nil) + splitMsg := strings.Split(msg, "--") + g.Assert(len(splitMsg)).Eql(2) + //encryptedMsg, iv := splitMsg[0], splitMsg[1] + var newMsg string + err = e.Decrypt(msg, &newMsg) + g.Assert(err).Eql(nil) + g.Assert(newMsg).Eql("my secret data") + }) + + g.It("can encrypt/decrypt an unsigned struct", func() { + type Person struct { + Id int `json:"id"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Age int `json:"age"` + } + data := Person{Id: 12, FirstName: "John", LastName: "Doe", Age: 42} + e := newCrypt() + msg, err := e.Encrypt(data) + g.Assert(err).Eql(nil) + splitMsg := strings.Split(msg, "--") + g.Assert(len(splitMsg)).Eql(2) + //encryptedMsg, iv := splitMsg[0], splitMsg[1] + var decryptedMsg Person + err = e.Decrypt(msg, &decryptedMsg) + g.Assert(err).Eql(nil) + g.Assert(decryptedMsg).Eql(data) + }) + + g.It("can round trip signed and encoded string", func() { + testData := "this is a test" + var e MessageEncryptor + for i := 0; i < 100; i++ { + e = newCrypt() + msg, err := e.EncryptAndSign(testData) + g.Assert(err).Eql(nil) + var output string + err = e.DecryptAndVerify(msg, &output) + g.Assert(err).Eql(nil) + if output != testData { + println(i, err.Error(), "FAILED", output, msg) + fmt.Printf("%#v\n", e) + } + g.Assert(output).Eql(testData) + } + }) + + g.It("can round trip signed and encoded struct", func() { + e := newCrypt() + testData := testStruct{Foo: "this is foo", Bar: 42} + msg, err := e.EncryptAndSign(testData) + g.Assert(err).Eql(nil) + var output testStruct + err = e.DecryptAndVerify(msg, &output) + g.Assert(err).Eql(nil) + g.Assert(output).Eql(testData) + }) + }) +} + +func TestDecryptingRailsSession(t *testing.T) { + g := Goblin(t) + + g.Describe("A Rails JSON session", func() { + cookieContent := "TDZIdC9GcEVRSnR0aFlqYTI1SmRWTmw3NWxpRkJZNDVMK0NIUXFlcThWWitLeVQzMFVBUTE2RU82RnRsUUxQWnhyWG95dFJSRDc0OVpkVzhGWXlIb1hERHhPdk5mYStkd3pVVUZNbE1vcDRqU01MYVZJMVpMWVI5SmIweFo1N2tqWTdZcVhyWmdnc2NhZUY2b1BBMlNKWkVsT0Y0aEVQcVVKaGRISk0zR3JLWXdjaFMxamN2aThVL2hBMHBmSGx5bGg4UjUzRFErejlQVEM0eUZjcStSM3VYUkNERjBMdUVqQzZaQk5ZNHpjRT0tLUhDQ2RraWpKRDBleUp1Rm1OeVA5Snc9PQ==--61cd94a037a0a006a01403952a652ddc5da1a597" + railsSecret := "f7b5763636f4c1f3ff4bd444eacccca295d87b990cc104124017ad70550edcfd22b8e89465338254e0b608592a9aac29025440bfd9ce53579835ba06a86f85f9" + encryptedCookieSalt := []byte("encrypted cookie") + encryptedSignedCookieSalt := []byte("signed encrypted cookie") + + kg := KeyGenerator{Secret: railsSecret} + secret := kg.CacheGenerate(encryptedCookieSalt, 32) + signSecret := kg.CacheGenerate(encryptedSignedCookieSalt, 64) + e := MessageEncryptor{Key: secret, SignKey: signSecret} + + g.It("can be decrypted", func() { + var session map[string]interface{} + err := e.DecryptAndVerify(cookieContent, &session) + j, _ := json.Marshal(session) + fmt.Printf("%s\n", j) + g.Assert(err).Eql(nil) + g.Assert(session["session_id"]).Eql("b2d63c07ea7a9d58e415e3672e3f31a2") + }) + }) + + g.Describe("A Rails JSON session with aes-256-gcm", func() { + cookieContent := "Co+XxC9PK1ptoHftqua6C3PNrlvk4EA09IpKho+wk5qbMi4jrl6SS2g6xexK68b8kjKWqXzCcT/ZjkbAO/0Sxm01JIK0zY/qGa56ogFaVViZKgaCGlSQYDWrVDm3mCSTlTzHDl3nrIjMffwNEn2x5IPHaQQoR0skkv3A17zejE4d18pRqRYaCuZLg2H04HWYv0Y/s88Kurmevw8w/8xUwLIV8P3SpszfMHEU--Cs17rTBCsResqqC5--ym0c0ZE+ts7wExyw/t35QA==" + railsSecret := "f7b5763636f4c1f3ff4bd444eacccca295d87b990cc104124017ad70550edcfd22b8e89465338254e0b608592a9aac29025440bfd9ce53579835ba06a86f85f9" + encryptedCookieSalt := []byte("authenticated encrypted cookie") + + kg := KeyGenerator{Secret: railsSecret} + secret := kg.CacheGenerate(encryptedCookieSalt, 32) + + fmt.Printf("%x\n", secret) + e := MessageEncryptor{Key: secret, Cipher: "aes-256-gcm"} + + g.It("can be decrypted", func() { + var session map[string]interface{} + err := e.DecryptAndVerify(cookieContent, &session) + g.Assert(err).Eql(nil) + g.Assert(session["session_id"]).Eql("b2d63c07ea7a9d58e415e3672e3f31a2") + }) + }) +} + +func ExampleMessageEncryptor_EncryptAndSign() { + type Person struct { + Id int `json:"id"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Age int `json:"age"` + } + john := Person{Id: 12, FirstName: "John", LastName: "Doe", Age: 42} + + k := GenerateRandomKey(32) + signKey := []byte("this is a secret!") + e := MessageEncryptor{Key: k, SignKey: signKey} + + // string encoding example + msg, err := e.EncryptAndSign("my secret data") + if err != nil { + panic(err) + } + fmt.Println(msg) + + // struct encoding example + msg, err = e.EncryptAndSign(john) + if err != nil { + panic(err) + } + fmt.Println(msg) +} + +func ExampleMessageEncryptor_DecryptAndVerify() { + + type Person struct { + Id int `json:"id"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Age int `json:"age"` + } + john := Person{Id: 12, FirstName: "John", LastName: "Doe", Age: 42} + + railsSecret := "f7b5763636f4c1f3ff4bd444eacccca295d87b990cc104124017ad70550edcfd22b8e89465338254e0b608592a9aac29025440bfd9ce53579835ba06a86f85f9" + encryptedCookieSalt := []byte("encrypted cookie") + encryptedSignedCookieSalt := []byte("signed encrypted cookie") + + kg := KeyGenerator{Secret: railsSecret} + // use 64 bit keys since the encryption uses 32 bytes + // but the signature uses 64. The crypto package handles that well. + secret := kg.CacheGenerate(encryptedCookieSalt, 32) + signSecret := kg.CacheGenerate(encryptedSignedCookieSalt, 64) + e := MessageEncryptor{Key: secret, SignKey: signSecret} + sessionString, err := e.EncryptAndSign(john) + if err != nil { + panic(err) + } + + // decrypting the person object contained in the session + var sessionContent Person + err = e.DecryptAndVerify(sessionString, &sessionContent) + if err != nil { + panic(err) + } + fmt.Printf("%#v\n", sessionContent) + + //Output: + // crypto.Person{Id:12, FirstName:"John", LastName:"Doe", Age:42} +} + +func ExampleMessageEncryptor_EncryptAndSignGCM() { + type Person struct { + Id int `json:"id"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Age int `json:"age"` + } + john := Person{Id: 12, FirstName: "John", LastName: "Doe", Age: 42} + + k := GenerateRandomKey(32) + e := MessageEncryptor{Key: k, Cipher: "aes-256-gcm"} + + // string encoding example + msg, err := e.EncryptAndSign("my secret data") + if err != nil { + panic(err) + } + fmt.Println(msg) + + // struct encoding example + msg, err = e.EncryptAndSign(john) + if err != nil { + panic(err) + } + fmt.Println(msg) +} + +func ExampleMessageEncryptor_DecryptAndVerifyGCM() { + + type Person struct { + Id int `json:"id"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Age int `json:"age"` + } + john := Person{Id: 12, FirstName: "John", LastName: "Doe", Age: 42} + + railsSecret := "f7b5763636f4c1f3ff4bd444eacccca295d87b990cc104124017ad70550edcfd22b8e89465338254e0b608592a9aac29025440bfd9ce53579835ba06a86f85f9" + authenticatedCookieSalt := []byte("authenticated encrypted cookie") + + kg := KeyGenerator{Secret: railsSecret} + secret := kg.CacheGenerate(authenticatedCookieSalt, 32) + e := MessageEncryptor{Key: secret, Cipher: "aes-256-gcm"} + sessionString, err := e.EncryptAndSign(john) + if err != nil { + panic(err) + } + + // decrypting the person object contained in the session + var sessionContent Person + err = e.DecryptAndVerify(sessionString, &sessionContent) + if err != nil { + panic(err) + } + fmt.Printf("%#v\n", sessionContent) + + //Output: + // crypto.Person{Id:12, FirstName:"John", LastName:"Doe", Age:42} +} diff --git a/goRailsYourself/crypto/message_verifier.go b/goRailsYourself/crypto/message_verifier.go new file mode 100644 index 0000000..9a9f038 --- /dev/null +++ b/goRailsYourself/crypto/message_verifier.go @@ -0,0 +1,132 @@ +package crypto + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "hash" + "strings" +) + +// MessageVerifier makes it easy to generate and verify messages which are +// signed to prevent tampering. +// +// This is useful for cases like remember-me tokens and auto-unsubscribe links +// where the session store isn't suitable or available. +type MessageVerifier struct { + // Secret of 32-bytes if using the default hashing. + Secret []byte + // Hasher defaults to sha1 if not set. + Hasher func() hash.Hash + // Serializer defines the way the data is serializer/deserialized. + Serializer MsgSerializer +} + +// Checks that the struct is properly set and ready for use. +func (crypt *MessageVerifier) IsValid() (bool, error) { + err := crypt.checkInit() + if err != nil { + return false, err + } + return true, nil +} + +// Verify() takes a base64 encoded message string joined to a digest by a double dash "--" +// and returns an error if anything wrong happen. +// If the verification worked, the target interface object passed is populated. +func (crypt *MessageVerifier) Verify(msg string, target interface{}) error { + // TODO: check that the target is a pointer. + err := crypt.checkInit() + if err != nil { + return err + } + + invalid := func(msg string) error { + return errors.New("Invalid signature - " + msg) + } + if msg == "" { + return invalid("empty message") + } + + dataDigest := strings.Split(msg, "--") + if len(dataDigest) != 2 { + return invalid("bad data --") + } + + data, digest := dataDigest[0], dataDigest[1] + if crypt.secureCompare(digest, crypt.DigestFor(data)) == false { + return invalid("bad data (compare)") + } + decodedData, err := base64.StdEncoding.DecodeString(data) + err = crypt.Serializer.Unserialize(string(decodedData), target) + return err +} + +// Generate() Converts an interface into a string containing the serialized data +// and a digest. +// The string can be passed around and tampering can be checked using the digest. +// See Verify() to extract the data out of the signed string. +func (crypt *MessageVerifier) Generate(value interface{}) (string, error) { + err := crypt.checkInit() + if err != nil { + return "", err + } + + data, err := crypt.Serializer.Serialize(value) + if err != nil { + return "", err + } + str := base64.StdEncoding.EncodeToString([]byte(data)) + digest := crypt.DigestFor(str) + return fmt.Sprintf("%s--%s", str, digest), nil +} + +// DigestFor returns the digest form of a string after hashing it via +// the verifier's digest and secret. +func (crypt *MessageVerifier) DigestFor(data string) string { + if crypt.Secret == nil { + return "Y U SET NO SECRET???!" + } + + mac := hmac.New(crypt.Hasher, crypt.Secret) + mac.Write([]byte(data)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// constant-time comparison algorithm to prevent timing attacks +func (crypt *MessageVerifier) secureCompare(strA, strB string) bool { + a := []byte(strA) + b := []byte(strB) + + if len(a) != len(b) { + return false + } + res := 0 + for i := 0; i < len(a); i++ { + res |= int(b[i]) ^ int(a[i]) + } + return res == 0 +} + +func (crypt *MessageVerifier) checkInit() error { + if crypt == nil { + return errors.New("MessageVerifier not set") + } + if crypt.Serializer == nil { + return errors.New("Serializer not set") + } + + if crypt.Hasher == nil { + // set a default hasher + crypt.Hasher = sha1.New + } + + if crypt.Secret == nil { + return errors.New("Secret not set") + } + + return nil +} diff --git a/goRailsYourself/crypto/message_verifier_test.go b/goRailsYourself/crypto/message_verifier_test.go new file mode 100644 index 0000000..7035f4a --- /dev/null +++ b/goRailsYourself/crypto/message_verifier_test.go @@ -0,0 +1,254 @@ +package crypto + +import ( + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "fmt" + . "github.com/franela/goblin" + "strings" + "testing" +) + +type testStruct struct { + Foo string + Bar int + Baz []string `json:",omitempty"` +} + +func reverse(s string) string { + runes := []rune(s) + for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { + runes[i], runes[j] = runes[j], runes[i] + } + return string(runes) +} + +func TestMessageVerifier(t *testing.T) { + g := Goblin(t) + + g.Describe("a malformed MessageVerifier", func() { + g.Describe("without a serializer", func() { + v := MessageVerifier{ + Secret: []byte("Hey, I'm a secret!"), + Hasher: sha1.New, + } + + g.It("won't generate messages", func() { + foo := "foo" + str, err := v.Generate(foo) + g.Assert(err.Error()).Eql("Serializer not set") + g.Assert(str).Eql("") + }) + + g.It("won't verify messages", func() { + var foo string + err := v.Verify("foo", foo) + g.Assert(err.Error()).Eql("Serializer not set") + }) + + }) + + g.Describe("without a hasher", func() { + secret := []byte("Hey, I'm a secret!") + v := MessageVerifier{ + Secret: secret, + Serializer: NullMsgSerializer{}, + } + + g.It("will generate messages using the default sha1 hasher", func() { + foo := "foo" + str, err := v.Generate(foo) + g.Assert(err).Eql(nil) + g.Assert(str != "").Eql(true) + }) + + g.It("will verify messages using the default sha1 hasher", func() { + vv := MessageVerifier{ + Secret: secret, + Hasher: sha1.New, + Serializer: NullMsgSerializer{}, + } + str, err := vv.Generate("this is a test") + g.Assert(err).Eql(nil) + + var result string + err = v.Verify(str, &result) + g.Assert(err).Eql(nil) + g.Assert(result).Eql("this is a test") + }) + + }) + + g.Describe("without a secret", func() { + v := MessageVerifier{ + Serializer: JsonMsgSerializer{}, + Hasher: sha1.New, + } + + g.It("won't generate messages", func() { + foo := "foo" + _, err := v.Generate(foo) + g.Assert(err.Error()).Eql("Secret not set") + }) + + g.It("won't verify messages", func() { + var foo string + err := v.Verify("foo", foo) + g.Assert(err.Error()).Eql("Secret not set") + }) + + }) + + }) + + g.Describe("MessageVerifier with a secret & the json serializer", func() { + + g.Describe("and using SHA1", func() { + v := MessageVerifier{ + Secret: []byte("Hey, I'm a secret!"), + Hasher: sha1.New, + Serializer: JsonMsgSerializer{}, + } + + g.It("properly digests a string", func() { + digest := v.DigestFor("eyJGb28iOiJmb28iLCJCYXIiOjQyfQ==") + g.Assert(digest).Eql("b1bdb9d2b372f19dcca800e5989ee7502f1b72a5") + }) + + g.It("can do a round trip verification", func() { + data := testStruct{Foo: "foo", Bar: 42} + generated, err := v.Generate(data) + g.Assert(err == nil).IsTrue() + g.Assert(generated).Eql("eyJGb28iOiJmb28iLCJCYXIiOjQyfQ==--b1bdb9d2b372f19dcca800e5989ee7502f1b72a5") + var verified testStruct + err = v.Verify(generated, &verified) + g.Assert(err == nil).IsTrue() + g.Assert(verified).Eql(data) + }) + + g.It("can catch tampered data", func() { + data := testStruct{Foo: "foo", Bar: 42} + msg, err := v.Generate(data) + // split + dh := strings.Split(msg, "--") + d, h := dh[0], dh[1] + var verified testStruct + err = v.Verify((d + "--" + h), &verified) + g.Assert(err).Eql(nil) + str := reverse(d) + "--" + h + err = v.Verify(str, &verified) + g.Assert(err.Error()).Eql("Invalid signature - bad data (compare)") + str = d + "--" + reverse(h) + err = v.Verify(str, &verified) + g.Assert(err.Error()).Eql("Invalid signature - bad data (compare)") + err = v.Verify("gargabe data", &verified) + g.Assert(err.Error()).Eql("Invalid signature - bad data --") + }) + }) + + g.Describe("and using SHA256", func() { + v := MessageVerifier{ + Secret: []byte("Hey, I'm a secret!"), + Hasher: sha256.New, + Serializer: JsonMsgSerializer{}, + } + + g.It("can do a round trip verification", func() { + data := testStruct{Foo: "foo", Bar: 42} + generated, err := v.Generate(data) + g.Assert(err == nil).IsTrue() + var verified testStruct + err = v.Verify(generated, &verified) + g.Assert(err == nil).IsTrue() + g.Assert(verified).Eql(data) + }) + }) + + g.Describe("and using SHA512", func() { + v := MessageVerifier{ + Secret: []byte("Hey, I'm a secret!"), + Hasher: sha512.New, + Serializer: JsonMsgSerializer{}, + } + + g.It("can do a round trip verification", func() { + data := testStruct{Foo: "foo", Bar: 42} + generated, err := v.Generate(data) + g.Assert(err == nil).IsTrue() + var verified testStruct + err = v.Verify(generated, &verified) + g.Assert(err == nil).IsTrue() + g.Assert(verified).Eql(data) + }) + }) + + g.Describe("and using md5", func() { + v := MessageVerifier{ + Secret: []byte("Hey, I'm a secret!"), + Hasher: md5.New, + Serializer: JsonMsgSerializer{}, + } + + g.It("can do a round trip verification", func() { + data := testStruct{Foo: "foo", Bar: 42} + generated, err := v.Generate(data) + g.Assert(err == nil).IsTrue() + var verified testStruct + err = v.Verify(generated, &verified) + g.Assert(err == nil).IsTrue() + g.Assert(verified).Eql(data) + }) + }) + + }) + + g.Describe("A MessageVerifier with a secret and a XML serializer", func() { + + v := MessageVerifier{ + Secret: []byte("Hey, I'm another secret!"), + Serializer: XMLMsgSerializer{}, + } + + g.It("can do a round trip verification using SHA1", func() { + data := testStruct{Foo: "foo", Bar: 42} + generated, err := v.Generate(data) + g.Assert(err == nil).IsTrue() + var verified testStruct + err = v.Verify(generated, &verified) + g.Assert(err == nil).IsTrue() + g.Assert(verified).Eql(data) + }) + + }) +} + +func ExampleMessageVerifier_Generate() { + v := MessageVerifier{ + Secret: []byte("Hey, I'm a secret!"), + Serializer: JsonMsgSerializer{}, + } + foo := map[string]interface{}{"foo": "this is foo", "bar": 42, "baz": []string{"bar", "baz"}} + generated, _ := v.Generate(foo) + fmt.Println(generated) + // Output: + // eyJiYXIiOjQyLCJiYXoiOlsiYmFyIiwiYmF6Il0sImZvbyI6InRoaXMgaXMgZm9vIn0=--895bf35965ebef12451372225ff3f73428f48e90 +} + +func ExampleMessageVerifier_Verify() { + v := MessageVerifier{ + Secret: []byte("Hey, I'm a secret!"), + Serializer: JsonMsgSerializer{}, + } + + data := testStruct{Foo: "foo", Bar: 42} + generated, _ := v.Generate(data) + fmt.Println(generated) + var verified testStruct + _ = v.Verify(generated, &verified) + fmt.Printf("%#v", verified) + // Output: + // eyJGb28iOiJmb28iLCJCYXIiOjQyfQ==--b1bdb9d2b372f19dcca800e5989ee7502f1b72a5 + // crypto.testStruct{Foo:"foo", Bar:42, Baz:[]string(nil)} +} diff --git a/goRailsYourself/crypto/null_msg_serializer.go b/goRailsYourself/crypto/null_msg_serializer.go new file mode 100644 index 0000000..efab810 --- /dev/null +++ b/goRailsYourself/crypto/null_msg_serializer.go @@ -0,0 +1,24 @@ +package crypto + +import ( + "errors" + "fmt" + "reflect" +) + +type NullMsgSerializer struct{} + +func (s NullMsgSerializer) Serialize(vptr interface{}) (string, error) { + return fmt.Sprint(vptr), nil +} + +// Can only deserialize to a string. +func (s NullMsgSerializer) Unserialize(data string, vptr interface{}) error { + typ := reflect.TypeOf(vptr) + if typ.Kind() != reflect.Ptr { + errors.New("You passed an interface which isn't a pointer") + } + v := reflect.ValueOf(vptr).Elem() + v.SetString(data) + return nil +} diff --git a/goRailsYourself/crypto/null_msg_serializer_test.go b/goRailsYourself/crypto/null_msg_serializer_test.go new file mode 100644 index 0000000..e95e3c3 --- /dev/null +++ b/goRailsYourself/crypto/null_msg_serializer_test.go @@ -0,0 +1,43 @@ +package crypto + +import ( + "testing" + + . "github.com/franela/goblin" +) + +func TestNullSerializerSerializer(t *testing.T) { + g := Goblin(t) + serializer := NullMsgSerializer{} + + g.Describe("a null serialized string", func() { + data := "this is a test" + output, err := serializer.Serialize(data) + g.Assert(err).Eql(err) + + g.It("can be deserialized", func() { + var o string + err := serializer.Unserialize(output, &o) + g.Assert(err).Eql(nil) + g.Assert(o).Eql(data) + }) + }) + + g.Describe("a null serialized struct", func() { + data := map[string]string{"foo": "matt", "bar": "aimonetti"} + output, err := serializer.Serialize(data) + + g.It("serializes properly", func() { + g.Assert(err).Eql(err) + g.Assert(output).Eql("map[bar:aimonetti foo:matt]") + }) + + g.It("can be deserialized", func() { + var o string + err := serializer.Unserialize(output, &o) + g.Assert(err).Eql(nil) + g.Assert(o).Eql("map[bar:aimonetti foo:matt]") + }) + }) + +} diff --git a/goRailsYourself/crypto/pkcs7_padding.go b/goRailsYourself/crypto/pkcs7_padding.go new file mode 100644 index 0000000..de784b6 --- /dev/null +++ b/goRailsYourself/crypto/pkcs7_padding.go @@ -0,0 +1,41 @@ +package crypto + +// PKCS7Pad() pads an byte array to be a multiple of 16 +// http://tools.ietf.org/html/rfc5652#section-6.3 +func PKCS7Pad(data []byte) []byte { + dataLen := len(data) + + var validLen int + if dataLen%16 == 0 { + validLen = dataLen + } else { + validLen = int(dataLen/16+1) * 16 + } + + paddingLen := validLen - dataLen + // The length of the padding is used as the byte we will + // append as a pad. + bitCode := byte(paddingLen) + padding := make([]byte, paddingLen) + for i := 0; i < paddingLen; i++ { + padding[i] = bitCode + } + return append(data, padding...) +} + +// PKCS7Unpad() removes any potential PKCS7 padding added. +func PKCS7Unpad(data []byte) []byte { + dataLen := len(data) + // Edge case + if dataLen == 0 { + return nil + } + // the last byte indicates the length of the padding to remove + paddingLen := int(data[dataLen-1]) + + // padding length can only be between 1-15 + if paddingLen < 16 { + return data[:dataLen-paddingLen] + } + return data +} diff --git a/goRailsYourself/crypto/pkcs7_padding_test.go b/goRailsYourself/crypto/pkcs7_padding_test.go new file mode 100644 index 0000000..2810bb7 --- /dev/null +++ b/goRailsYourself/crypto/pkcs7_padding_test.go @@ -0,0 +1,63 @@ +package crypto + +import ( + "bytes" + "testing" +) + +var ( + cases = []struct { + in, out []byte + }{ + 0: { + nil, + nil, + }, + 1: { + []byte{0x00}, + []byte{0x00, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f}, + }, + 2: { + []byte{0x00, 0x00}, + []byte{0x00, 0x00, 0x0e, 0x0e, 0x0e, 0x0e, 0x0e, 0x0e, 0x0e, 0x0e, 0x0e, 0x0e, 0x0e, 0x0e, 0x0e, 0x0e}, + }, + 3: { + []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a}, + }, + 4: { + []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x04, 0x04, 0x04}, + }, + 5: { + []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x02}, + }, + 6: { + []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, + }, + 7: { + []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f}, + }, + } +) + +func TestPKCS7Pad(t *testing.T) { + for i, c := range cases { + got := PKCS7Pad(c.in) + if bytes.Compare(c.out, got) != 0 { + t.Errorf("%d: expected %x, got %x", i, c.out, got) + } + } +} + +func TestPKCS7Unpad(t *testing.T) { + for i, c := range cases { + got := PKCS7Unpad(c.out) + if bytes.Compare(c.in, got) != 0 { + t.Errorf("%d: expected %x, got %x", i, c.in, got) + } + } +} diff --git a/goRailsYourself/crypto/xml_msg_serializer.go b/goRailsYourself/crypto/xml_msg_serializer.go new file mode 100644 index 0000000..f681aa7 --- /dev/null +++ b/goRailsYourself/crypto/xml_msg_serializer.go @@ -0,0 +1,20 @@ +package crypto + +import ( + "encoding/xml" +) + +type XMLMsgSerializer struct { +} + +func (s XMLMsgSerializer) Serialize(v interface{}) (string, error) { + b, err := xml.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil +} + +func (s XMLMsgSerializer) Unserialize(data string, v interface{}) error { + return xml.Unmarshal([]byte(data), v) +} diff --git a/goRailsYourself/crypto/xml_msg_serializer_test.go b/goRailsYourself/crypto/xml_msg_serializer_test.go new file mode 100644 index 0000000..99a8812 --- /dev/null +++ b/goRailsYourself/crypto/xml_msg_serializer_test.go @@ -0,0 +1,44 @@ +package crypto + +import ( + . "github.com/franela/goblin" + "testing" +) + +func TestXmlSerializerSerializer(t *testing.T) { + g := Goblin(t) + serializer := XMLMsgSerializer{} + + g.Describe("a xml serialized string", func() { + data := "this is a test" + output, err := serializer.Serialize(data) + g.Assert(err).Eql(err) + + g.It("can be deserialized", func() { + var o string + err := serializer.Unserialize(output, &o) + g.Assert(err).Eql(nil) + g.Assert(o).Eql(data) + }) + }) + + g.Describe("a xml serialized struct", func() { + type Person struct { + Id int `xml:"id,attr"` + FirstName string `xml:"name>first"` + LastName string `xml:"name>last"` + Age int `xml:"age"` + } + data := Person{Id: 13, FirstName: "John", LastName: "Doe", Age: 42} + output, err := serializer.Serialize(data) + g.Assert(err).Eql(err) + + g.It("can be deserialized", func() { + var o Person + err := serializer.Unserialize(output, &o) + g.Assert(err).Eql(nil) + g.Assert(o).Eql(data) + }) + }) + +} diff --git a/goRailsYourself/go.mod b/goRailsYourself/go.mod new file mode 100644 index 0000000..ae6a8e6 --- /dev/null +++ b/goRailsYourself/go.mod @@ -0,0 +1,9 @@ +module github.com/mattetti/goRailsYourself + +go 1.15 + +require ( + github.com/fiam/gounidecode v0.0.0-20150629112515-8deddbd03fec + github.com/franela/goblin v0.0.0-20201006155558-6240afcb2eb7 + golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad +) diff --git a/goRailsYourself/go.sum b/goRailsYourself/go.sum new file mode 100644 index 0000000..3b5d965 --- /dev/null +++ b/goRailsYourself/go.sum @@ -0,0 +1,12 @@ +github.com/fiam/gounidecode v0.0.0-20150629112515-8deddbd03fec h1:XvkU8wCqlvrrxuEw4h11yu9yq8ciB5w2Js+VSwp0WWQ= +github.com/fiam/gounidecode v0.0.0-20150629112515-8deddbd03fec/go.mod h1:WuPQ88SgkK3OxlJQxlU/PBVn8FOC1JPjXINk7JhOQOA= +github.com/franela/goblin v0.0.0-20201006155558-6240afcb2eb7 h1:eUae9KtuHjNg5e7DYkn57S/M/ndIICmV1bWs9ejYCx4= +github.com/franela/goblin v0.0.0-20201006155558-6240afcb2eb7/go.mod h1:VzmDKDJVZI3aJmnRI9VjAn9nJ8qPPsN1fqzr9dqInIo= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/goRailsYourself/inflector/inflector.go b/goRailsYourself/inflector/inflector.go new file mode 100644 index 0000000..cedb0fa --- /dev/null +++ b/goRailsYourself/inflector/inflector.go @@ -0,0 +1,40 @@ +// The inflector package ports some of Rails' ActiveSupport functions that +// can be useful outside of Rails. +// +// Rails documentation http://api.rubyonrails.org/classes/ActiveSupport/Inflector.html +package inflector + +import ( + "github.com/fiam/gounidecode/unidecode" + "regexp" + "strings" +) + +var parameterizeReplacementRegexp = regexp.MustCompile("(?i)[^a-z0-9-_]+") + +// Replaces special characters in a string so that it may be used as part of +// a 'pretty' URL. +// +// Rails documentation: http://api.rubyonrails.org/classes/ActiveSupport/Inflector.html#method-i-parameterize +func Parameterize(str, sep string) string { + // replace accented chars with their ascii equivalents + str = Transliterate(str) + // Turn unwanted chars into the separator + strB := parameterizeReplacementRegexp.ReplaceAllLiteral([]byte(str), []byte(sep)) + // No more than one of the separator in a row. + re := regexp.MustCompile(sep + `{2,}`) + strB = re.ReplaceAllLiteral(strB, []byte(sep)) + // Remove leading/trailing separator + re = regexp.MustCompile(`(?i)^` + sep + `|` + sep + `$`) + strB = re.ReplaceAllLiteral(strB, []byte{}) + str = string(strB) + // return a lower case version + return strings.ToLower(str) +} + +// Replaces non-ASCII characters with an ASCII approximation, or if none +// Transliterate("Ærøskøbing") => "AEroskobing" +// Rails documentation: http://api.rubyonrails.org/classes/ActiveSupport/Inflector.html#method-i-transliterate +func Transliterate(str string) string { + return unidecode.Unidecode(str) +} diff --git a/goRailsYourself/inflector/inflector_test.go b/goRailsYourself/inflector/inflector_test.go new file mode 100644 index 0000000..28b6973 --- /dev/null +++ b/goRailsYourself/inflector/inflector_test.go @@ -0,0 +1,66 @@ +package inflector + +import ( + "fmt" + . "github.com/franela/goblin" + "testing" +) + +func ExampleParameterize() { + fmt.Println(Parameterize("Matt Aïmonetti", "-")) + fmt.Println(Parameterize("Ærøskøbing!", "-")) + fmt.Println(Parameterize("Random text with *(bad)* characters", "-")) + // Output: matt-aimonetti + // aeroskobing + // random-text-with-bad-characters +} + +func TestParameterize(t *testing.T) { + g := Goblin(t) + g.Describe("Parameterize", func() { + + g.It("Should convert to lower case ", func() { + g.Assert(Parameterize("Matt", "-")).Equal("matt") + }) + + g.It("Should transliterate", func() { + expectations := map[string]string{ + "Ærøskøbing": "aeroskobing", + "mon école": "mon-ecole", + "et ta sœur": "et-ta-soeur", + } + for input, output := range expectations { + g.Assert(Parameterize(input, "-")).Equal(output) + } + }) + + g.It("Should replace any unwanted chars by the separator", func() { + expectations := map[string]string{ + "Matt Aimonetti": "matt-aimonetti", + "Donald E. Knuth": "donald-e-knuth", + "mon école": "mon-ecole", + "Random text with *(bad)* characters": "random-text-with-bad-characters", + "Trailing bad characters!@#": "trailing-bad-characters", + "!@#Leading bad characters": "leading-bad-characters", + } + for input, output := range expectations { + g.Assert(Parameterize(input, "-")).Equal(output) + } + }) + + g.It("Should allow for underscore", func() { + g.Assert(Parameterize("Allow_Under_Scores", "-")).Equal("allow_under_scores") + }) + + g.It("Should squeeze separators", func() { + g.Assert(Parameterize("Squeeze separators", "-")).Equal("squeeze-separators") + }) + }) +} + +func ExampleTransliterate() { + fmt.Println(Transliterate("Ærøskøbing")) + fmt.Println(Transliterate("Ma sœur va à l'école")) + // Output: AEroskobing + // Ma soeur va a l'ecole +} From c80eabc3017159f453cbe3312e6bada4c0432e29 Mon Sep 17 00:00:00 2001 From: Jordan Hiltunen Date: Mon, 16 Mar 2026 13:56:11 -0400 Subject: [PATCH 2/5] :bug: inline PKCS7Pad bugfix from https://github.com/mattetti/goRailsYourself/pull/8 --- goRailsYourself/crypto/aes_cbc.go | 8 -------- goRailsYourself/crypto/pkcs7_padding.go | 12 ++++-------- goRailsYourself/crypto/pkcs7_padding_test.go | 2 +- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/goRailsYourself/crypto/aes_cbc.go b/goRailsYourself/crypto/aes_cbc.go index 3c40f90..1ab36ff 100644 --- a/goRailsYourself/crypto/aes_cbc.go +++ b/goRailsYourself/crypto/aes_cbc.go @@ -1,7 +1,6 @@ package crypto import ( - "bytes" "crypto/aes" "crypto/cipher" "crypto/rand" @@ -97,12 +96,5 @@ func (crypt *MessageEncryptor) aesCbcDecrypt(encryptedMsg string, target interfa mode.CryptBlocks(ciphertext, ciphertext) unPaddedCiphertext := PKCS7Unpad(ciphertext) - // In some cases, Rails sends us messages padded with 0x10 (while this package only pads with 0x01-0x0f). - // For now, we handle this case here when the Serializer is JSON (so we know that 0x10 is actually a padding - // and not valid data - because this is an invalid json character). - if _, ok := crypt.Serializer.(JsonMsgSerializer); ok { - unPaddedCiphertext = bytes.TrimRight(unPaddedCiphertext, "\x10") - } - return crypt.Serializer.Unserialize(string(unPaddedCiphertext), target) } diff --git a/goRailsYourself/crypto/pkcs7_padding.go b/goRailsYourself/crypto/pkcs7_padding.go index de784b6..d9f8647 100644 --- a/goRailsYourself/crypto/pkcs7_padding.go +++ b/goRailsYourself/crypto/pkcs7_padding.go @@ -5,12 +5,7 @@ package crypto func PKCS7Pad(data []byte) []byte { dataLen := len(data) - var validLen int - if dataLen%16 == 0 { - validLen = dataLen - } else { - validLen = int(dataLen/16+1) * 16 - } + validLen := int(dataLen/16+1) * 16 paddingLen := validLen - dataLen // The length of the padding is used as the byte we will @@ -33,8 +28,9 @@ func PKCS7Unpad(data []byte) []byte { // the last byte indicates the length of the padding to remove paddingLen := int(data[dataLen-1]) - // padding length can only be between 1-15 - if paddingLen < 16 { + if paddingLen == dataLen { + return nil + } else if paddingLen < dataLen { return data[:dataLen-paddingLen] } return data diff --git a/goRailsYourself/crypto/pkcs7_padding_test.go b/goRailsYourself/crypto/pkcs7_padding_test.go index 2810bb7..6ccb249 100644 --- a/goRailsYourself/crypto/pkcs7_padding_test.go +++ b/goRailsYourself/crypto/pkcs7_padding_test.go @@ -11,7 +11,7 @@ var ( }{ 0: { nil, - nil, + []byte{0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10}, }, 1: { []byte{0x00}, From 41f881e7de2f167f289b8840ca2b871b1c18e60e Mon Sep 17 00:00:00 2001 From: Jordan Hiltunen Date: Mon, 16 Mar 2026 14:06:13 -0400 Subject: [PATCH 3/5] :rotating_light: goRailsYourself/crypto/doc.go: clean up commentary formatting --- goRailsYourself/crypto/doc.go | 105 ++++++++++---------- goRailsYourself/crypto/key_generator.go | 2 +- goRailsYourself/crypto/message_encryptor.go | 5 +- 3 files changed, 56 insertions(+), 56 deletions(-) diff --git a/goRailsYourself/crypto/doc.go b/goRailsYourself/crypto/doc.go index b203f6f..29f2ce6 100644 --- a/goRailsYourself/crypto/doc.go +++ b/goRailsYourself/crypto/doc.go @@ -4,31 +4,30 @@ // license that can be found at https://opensource.org/licenses/MIT /* -Package crypto ports some of Ruby on Rails' crypto: - - version 4+: encrypted & signed messages (aes-cbc) - - version 5.2+: encrypted & authenticated messages (aes-256-gcm) +Package crypto ports some of Ruby on Rails' crypto: + * version 4+: encrypted & signed messages (aes-cbc) + * version 5.2+: encrypted & authenticated messages (aes-256-gcm) Messages can be shared between a Ruby app and a Go app. That said, this library is useful to anyone wanting to encrypt/sign/authenticate data. The initial focus of this package was to be able to easily share a Rails web session with a Go app. Rails uses three classes provided by ActiveSupport (a library used and maintained by the Rails team) - - MessageEncryptor - - MessageVerifier - - KeyGenerator - + * MessageEncryptor + * MessageVerifier + * KeyGenerator to encrypt and sign sessions. In order to read/write a cookie session, a Go app needs to be able to verify, decrypt/encrypt sign the session data based on a shared secret. -# Key components of this package +Key components of this package The main components of this package are: - - MessageEncryptor - - MessageVerifier - - KeyGenerator + * MessageEncryptor + * MessageVerifier + * KeyGenerator The difference between MessageVerifier and MessageEncryptor is that you want to use MessageEncryptor when you don't want the content of the data @@ -39,12 +38,11 @@ Keygenerator is used to generate derived keys from a given secret. If you want to generate a random key that isn't derived, look at the GenerateRandomKey function. -# Session serializer +Session serializer Since Rails 5.2, the default session serializer can be set to use JSON by setting: - - Rails.application.config.action_dispatch.cookies_serializer = :json. + Rails.application.config.action_dispatch.cookies_serializer = :json. In older Rails versions, it is necessary to make changes in order to move away from the default session serializer (Marhsal). To be able to share the @@ -58,40 +56,40 @@ available JSON, XML and Null, the last serializer is basically a no-op serializer used when the data doesn't need serialization and can be transported as strings. -# Rails session flow +Rails session flow It's important to understand how Rails handles the crypto around the session. Here is a quick and high level of what Rails does (Ruby code): - # Secret set in the app. - secret_key_base = "f7b5763636f4c1f3ff4bd444eacccca295d87b990cc104124017ad70550edcfd22b8e89465338254e0b608592a9aac29025440bfd9ce53579835ba06a86f85f9" - - # Rails 4+ / aes-cbc: - key_generator = ActiveSupport::CachingKeyGenerator.new(ActiveSupport::KeyGenerator.new(secret_key_base, iterations: 1000)) - secret = key_generator.generate_key("encrypted cookie") - sign_secret = key_generator.generate_key("signed encrypted cookie") - - encryptor = ActiveSupport::MessageEncryptor.new(secret, sign_secret, { serializer: JsonSessionSerializer } ) - # encrypt and sign the content of the session: - encrypted_message = encryptor.encrypt_and_sign({msg: "hello world"}) - # The encrypted and signed message is stored in the session cookie - # To decrypt and verify it: - # encryptor.decrypt_and_verify(encrypted_message) # => {:msg => "hello world"} - - # Rails 5.2+ / aes-256-gcm: - key_generator = ActiveSupport::CachingKeyGenerator.new(ActiveSupport::KeyGenerator.new(secret_key_base, iterations: 1000)) - secret = key_generator.generate_key("authenticated encrypted cookie", 32) - encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: 'aes-256-gcm', serializer: JSON) - # encrypt and authenticate the content of the session: - encrypted_message = encryptor.encrypt_and_sign({msg: "hello world"}) - # The encrypted and authenticated message is stored in the session cookie - # To authenticate and decrypt it: - # encryptor.decrypt_and_verify(encrypted_message) # => {:msg => "hello world"} + # Secret set in the app. + secret_key_base = "f7b5763636f4c1f3ff4bd444eacccca295d87b990cc104124017ad70550edcfd22b8e89465338254e0b608592a9aac29025440bfd9ce53579835ba06a86f85f9" + + # Rails 4+ / aes-cbc: + key_generator = ActiveSupport::CachingKeyGenerator.new(ActiveSupport::KeyGenerator.new(secret_key_base, iterations: 1000)) + secret = key_generator.generate_key("encrypted cookie") + sign_secret = key_generator.generate_key("signed encrypted cookie") + + encryptor = ActiveSupport::MessageEncryptor.new(secret, sign_secret, { serializer: JsonSessionSerializer } ) + # encrypt and sign the content of the session: + encrypted_message = encryptor.encrypt_and_sign({msg: "hello world"}) + # The encrypted and signed message is stored in the session cookie + # To decrypt and verify it: + # encryptor.decrypt_and_verify(encrypted_message) # => {:msg => "hello world"} + + # Rails 5.2+ / aes-256-gcm: + key_generator = ActiveSupport::CachingKeyGenerator.new(ActiveSupport::KeyGenerator.new(secret_key_base, iterations: 1000)) + secret = key_generator.generate_key("authenticated encrypted cookie", 32) + encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: 'aes-256-gcm', serializer: JSON) + # encrypt and authenticate the content of the session: + encrypted_message = encryptor.encrypt_and_sign({msg: "hello world"}) + # The encrypted and authenticated message is stored in the session cookie + # To authenticate and decrypt it: + # encryptor.decrypt_and_verify(encrypted_message) # => {:msg => "hello world"} The equivalent in Go is available in the documentation examples: http://godoc.org/github.com/mattetti/goRailsYourself/crypto#pkg-examples -# Derived keys +Derived keys A few important things need to be mentioned. Rails uses a unique secret that is used to derive different keys using a default salt. @@ -117,25 +115,25 @@ lets the OpenSSL wrapper truncate the key. I, however recommend you generate keys of different length to avoid any confusion. Here is an example for aes-cbc (Rails 4+): - railsSecret := "f7b5763636f4c1f3ff4bd444eacccca295d87b990cc104124017ad70550edcfd22b8e89465338254e0b608592a9aac29025440bfd9ce53579835ba06a86f85f9" - encryptedCookieSalt := []byte("encrypted cookie") - encryptedSignedCookieSalt := []byte("signed encrypted cookie") + railsSecret := "f7b5763636f4c1f3ff4bd444eacccca295d87b990cc104124017ad70550edcfd22b8e89465338254e0b608592a9aac29025440bfd9ce53579835ba06a86f85f9" + encryptedCookieSalt := []byte("encrypted cookie") + encryptedSignedCookieSalt := []byte("signed encrypted cookie") - kg := KeyGenerator{Secret: railsSecret} - secret := kg.CacheGenerate(encryptedCookieSalt, 32) - signSecret := kg.CacheGenerate(encryptedSignedCookieSalt, 64) - e := MessageEncryptor{Key: secret, SignKey: signSecret} + kg := KeyGenerator{Secret: railsSecret} + secret := kg.CacheGenerate(encryptedCookieSalt, 32) + signSecret := kg.CacheGenerate(encryptedSignedCookieSalt, 64) + e := MessageEncryptor{Key: secret, SignKey: signSecret} Here is an example for aes-256-gcm (Rails 5.2+): - railsSecret := "f7b5763636f4c1f3ff4bd444eacccca295d87b990cc104124017ad70550edcfd22b8e89465338254e0b608592a9aac29025440bfd9ce53579835ba06a86f85f9" - authenticatedCookieSalt := []byte("authenticated encrypted cookie") + railsSecret := "f7b5763636f4c1f3ff4bd444eacccca295d87b990cc104124017ad70550edcfd22b8e89465338254e0b608592a9aac29025440bfd9ce53579835ba06a86f85f9" + authenticatedCookieSalt := []byte("authenticated encrypted cookie") - kg := KeyGenerator{Secret: railsSecret} - secret := kg.CacheGenerate(authenticated, 32) - e := MessageEncryptor{Key: secret, Cipher: "aes-256-gcm"} + kg := KeyGenerator{Secret: railsSecret} + secret := kg.CacheGenerate(authenticated, 32) + e := MessageEncryptor{Key: secret, Cipher: "aes-256-gcm"} -# Without Ruby +Without Ruby The encryption used in Rails isn't specific to Ruby and this library can be used to communicate with apps that aren't in Ruby. As a matter of @@ -146,5 +144,6 @@ has been tested and vested by many people and is safe to use. It is recommended that new applications use the "aes-256-gcm" mode rather than the "aes-cbc" mode, as the prior is a less error prone scheme and does not rely on now out of favor cryptographic primitives. + */ package crypto diff --git a/goRailsYourself/crypto/key_generator.go b/goRailsYourself/crypto/key_generator.go index 39c4add..103d676 100644 --- a/goRailsYourself/crypto/key_generator.go +++ b/goRailsYourself/crypto/key_generator.go @@ -1,9 +1,9 @@ package crypto import ( + "golang.org/x/crypto/pbkdf2" "crypto/sha1" "fmt" - "golang.org/x/crypto/pbkdf2" ) // KeyGenerator is a simple wrapper around a PBKDF2 implementation. diff --git a/goRailsYourself/crypto/message_encryptor.go b/goRailsYourself/crypto/message_encryptor.go index 0606ad9..f0fe8ef 100644 --- a/goRailsYourself/crypto/message_encryptor.go +++ b/goRailsYourself/crypto/message_encryptor.go @@ -5,6 +5,7 @@ import ( "errors" ) +// // MessageEncryptor is a simple way to encrypt values which get stored // somewhere you don't trust. // @@ -15,8 +16,8 @@ import ( // where you don't want users to be able to determine the value of the payload. // // Different kind of ciphers are supported: -// - aes-cbc - Rails' default until 5.2, requires a verifier -// - aes-256-gcm - Rails 5.2+ default, ignores verifier. +// - aes-cbc - Rails' default until 5.2, requires a verifier +// - aes-256-gcm - Rails 5.2+ default, ignores verifier. // // Note: The old Rails default serializer, Marshal is neither safe or // portable across langauges, use the JSON serializer. From 1f356980b6f886e2a9932660dabb61bf20cc73de Mon Sep 17 00:00:00 2001 From: Jordan Hiltunen Date: Mon, 16 Mar 2026 14:21:33 -0400 Subject: [PATCH 4/5] :fire: goRailsYourself/*: drop unnecessary files (gitignore, .idea, .github, etc) --- goRailsYourself/.github/workflows/go.yml | 28 ------------------------ goRailsYourself/.gitignore | 27 ----------------------- goRailsYourself/.idea/.gitignore | 10 --------- goRailsYourself/.travis.yml | 10 --------- 4 files changed, 75 deletions(-) delete mode 100644 goRailsYourself/.github/workflows/go.yml delete mode 100644 goRailsYourself/.gitignore delete mode 100644 goRailsYourself/.idea/.gitignore delete mode 100644 goRailsYourself/.travis.yml diff --git a/goRailsYourself/.github/workflows/go.yml b/goRailsYourself/.github/workflows/go.yml deleted file mode 100644 index cd63a78..0000000 --- a/goRailsYourself/.github/workflows/go.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Go - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - - build: - name: Build - runs-on: ubuntu-latest - steps: - - - name: Set up Go 1.x - uses: actions/setup-go@v2 - with: - go-version: ^1.13 - - - name: Check out code into the Go module directory - uses: actions/checkout@v2 - - - name: Build - run: go build -v ./... - - - name: Test - run: go test -v ./... diff --git a/goRailsYourself/.gitignore b/goRailsYourself/.gitignore deleted file mode 100644 index b7c6320..0000000 --- a/goRailsYourself/.gitignore +++ /dev/null @@ -1,27 +0,0 @@ -# Compiled Object files, Static and Dynamic libs (Shared Objects) -*.o -*.a -*.so - -# Folders -_obj -_test - -# Architecture specific extensions/prefixes -*.[568vq] -[568vq].out - -*.cgo1.go -*.cgo2.c -_cgo_defun.c -_cgo_gotypes.go -_cgo_export.* - -_testmain.go - -*.test -*.exe - -# vim temp files -.*.swp -.*.swo diff --git a/goRailsYourself/.idea/.gitignore b/goRailsYourself/.idea/.gitignore deleted file mode 100644 index ab1f416..0000000 --- a/goRailsYourself/.idea/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Ignored default folder with query files -/queries/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/goRailsYourself/.travis.yml b/goRailsYourself/.travis.yml deleted file mode 100644 index f017260..0000000 --- a/goRailsYourself/.travis.yml +++ /dev/null @@ -1,10 +0,0 @@ -language: go -go: - - 1.1 - - release - - tip - -install: - - go get -u github.com/franela/goblin - - go get -u golang.org/x/crypto/pbkdf2 - - go get -u github.com/fiam/gounidecode/unidecode From 27c219c12950a643b2108db1ab81c6df37bced45 Mon Sep 17 00:00:00 2001 From: Jordan Hiltunen Date: Mon, 16 Mar 2026 14:21:52 -0400 Subject: [PATCH 5/5] :recycle: cookies.go: rely on inlined goRailsYourself package --- cookies.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cookies.go b/cookies.go index cad401e..4f260c2 100644 --- a/cookies.go +++ b/cookies.go @@ -5,7 +5,7 @@ import ( "net/http" "time" - "github.com/divoxx/goRailsYourself/crypto" + "github.com/doximity/cookies/goRailsYourself/crypto" ) // CookieEncryptor implements cookie encryption and signing to allow securely storing sensitive