Last active
October 11, 2022 20:20
-
-
Save ivan3bx/0769a3c20751eef9bce16774e6093996 to your computer and use it in GitHub Desktop.
Reading Rails 7x encrypted credentials from Go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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