Skip to content

Instantly share code, notes, and snippets.

@ivan3bx
Last active October 11, 2022 20:20
Show Gist options
  • Save ivan3bx/0769a3c20751eef9bce16774e6093996 to your computer and use it in GitHub Desktop.
Save ivan3bx/0769a3c20751eef9bce16774e6093996 to your computer and use it in GitHub Desktop.
Reading Rails 7x encrypted credentials from Go
package config
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"errors"
"io"
"os"
"strings"
)
const (
RAILS_ENV_KEY = "RAILS_MASTER_KEY"
)
var _ io.Reader = &EncryptedFile{}
type EncryptedFile struct {
KeyPath string
Path string
}
func (cr EncryptedFile) Read(p []byte) (int, error) {
var (
key []byte
encryptedData []byte
cipherText []byte
iv []byte
err error
)
if encryptedData, err = os.ReadFile(cr.Path); err != nil {
return 0, err
}
if key, err = cr.readKey(cr.KeyPath); err != nil {
return 0, err
}
if cipherText, iv, err = cr.parseData(encryptedData); err != nil {
return 0, err
}
res := cr.decrypt(key, cipherText, iv)
// Extract payload from Rails binary format
if res, err = deserialize(res); err != nil {
return 0, err
}
return copy(p, res), io.EOF
}
func (cr *EncryptedFile) readKey(keyPath string) ([]byte, error) {
if envKey := os.Getenv(RAILS_ENV_KEY); len(envKey) > 0 {
return hex.DecodeString(envKey)
}
key, err := os.ReadFile(keyPath)
if err != nil {
return nil, err
}
key = bytes.TrimSpace(key)
if key, err = hex.DecodeString(string(key)); err != nil {
return nil, err
}
return key, nil
}
func (cr *EncryptedFile) parseData(encryptedData []byte) (cipherText []byte, iv []byte, err error) {
var (
authData []byte
)
segments := strings.Split(string(encryptedData), "--")
if cipherText, err = base64.StdEncoding.DecodeString(segments[0]); err != nil {
return nil, nil, err
}
if iv, err = base64.StdEncoding.DecodeString(segments[1]); err != nil {
return nil, nil, err
}
if authData, err = base64.StdEncoding.DecodeString(segments[2]); err != nil {
return nil, nil, err
}
return bytes.Join([][]byte{cipherText, authData}, []byte{}), iv, nil
}
func (cr *EncryptedFile) decrypt(key []byte, ciphertext []byte, iv []byte) []byte {
block, err := aes.NewCipher(key)
if err != nil {
panic(err.Error())
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
panic(err.Error())
}
plaintext, err := aesgcm.Open(nil, iv, ciphertext, nil)
if err != nil {
panic(err.Error())
}
return plaintext
}
func deserialize(buf []byte) ([]byte, error) {
const (
ASCII8bit = 0x22
OFFSET_2_BYTES = 0x02
OFFSET_4_BYTES = 0x03
OFFSET_5_BYTES = 0x04
)
var (
header = buf[:2]
objType = buf[2]
lenIndicator = buf[3]
)
if !bytes.Equal(header, []byte{0x04, 0x08}) {
return nil, errors.New("invalid serialization header")
}
if objType != ASCII8bit {
return nil, errors.New("data does not encode an ASCII-8BIT value")
}
/*
see https://docs.ruby-lang.org/en/2.1.0/marshal_rdoc.html
*/
switch lenIndicator {
case OFFSET_2_BYTES:
// Following two bytes store length
length := binary.LittleEndian.Uint16(buf[4:6])
return buf[6:(length + 6)], nil
case OFFSET_4_BYTES:
// Following four bytes store length
length := binary.LittleEndian.Uint16(buf[4:7])
return buf[7:(length + 7)], nil
case OFFSET_5_BYTES:
// Following five bytes store length
length := binary.LittleEndian.Uint16(buf[4:8])
return buf[8:(length + 8)], nil
case 0x01, 0xff, 0xfe, 0xfd, 0xfc:
return nil, errors.New("unsupported string length")
default:
// In this case, length indicator defined as "object length + 5"
// so we reduce it by one to get the byte array offset
return buf[4 : lenIndicator-1], nil
}
}
package api
import (
"io/ioutil"
"os"
"testing"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v2"
)
/*
Key:
testdata/master.key
7d6d07a83c74bad3a98346821cd6744f
File:
testdata/credentials.yml.enc
Contents:
# generated with rails 7.0.3
key_1: blue
key_2: green
section:
key_3: red
*/
func TestEncryptedFile(t *testing.T) {
testCases := []struct {
name string
setup func()
keyFile string
encryptedFile string
expectedFile string
expectErr bool
}{
{
name: "processes rails credentials",
keyFile: "./testdata/master.key",
encryptedFile: "./testdata/credentials.yml.enc",
expectedFile: "./testdata/credentials.plain.yml",
},
{
name: "fetches key from environment",
setup: func() {
// should override the 'keyFile'
os.Setenv("RAILS_MASTER_KEY", "7d6d07a83c74bad3a98346821cd6744f")
},
encryptedFile: "./testdata/credentials.yml.enc",
expectedFile: "./testdata/credentials.plain.yml",
},
{
name: "fails if keyfile not found",
keyFile: "./not-exist-file",
expectErr: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
defer func() {
os.Unsetenv("RAILS_MASTER_KEY")
}()
creds := EncryptedFile{
KeyPath: tc.keyFile,
Path: tc.encryptedFile,
}
if tc.setup != nil {
tc.setup()
}
actual, err := ioutil.ReadAll(creds)
if tc.expectErr {
assert.Error(t, err)
return
} else {
assert.NoError(t, err)
}
// Check file contents if specified
if expected, err := os.ReadFile(tc.expectedFile); err == nil {
assert.Equal(t, string(expected), string(actual))
}
// Check for valid YAML
val := struct{}{}
assert.NoError(t, yaml.Unmarshal(actual, &val))
})
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment