Exploring 1Password's Crypto With Go
1Password is a simple and convenient application to keep your passwords safe. Im a big fan since 2013 when i got super tired of maintaining lots of passwords and decided to finally end the struggle of remembering the damn thing for each account. It paid off and i now have over 100+ passwords and secure notes. 1Password also provides an iPhone app with TouchID support. Handy!
Until now i had absolutely zero interest in how 1Password works under the hood. My previous post also involved some data encryption so i decided to dig in and research how does 1Password manage to keep its secrets. It also does not hurt to know more things about cryptography and its applications to software development in general.
Vault / Agile Keychain
First things first: where's the stuff? Under the hood 1Password keeps all its data in a single Sqlite3 database, called Vault. If you're using OSX, vault files are located at the following path:
ls -al ~/Library/Containers/2BUA8C4S2C.com.agilebits.onepassword-osx-helper/Data/Library/Data
-rw-r--r--@ 1 sosedoff staff 421888 May 28 20:43 OnePassword.sqlite
-rw-r--r--@ 1 sosedoff staff 32768 May 29 20:02 OnePassword.sqlite-shm
-rw-r--r--@ 1 sosedoff staff 1697472 May 30 08:39 OnePassword.sqlite-wal
OnePassword.sqlite is the main database file, OnePassword.sqlite-wal is file used for Write Ahead Logging and OnePassword.sqlite-shm is a temp file used to share memory during updates. It's highly recommended to not mess with the database directly and use sync feature instead, which is designed to export 1password's data to cloud providers. Sqlite database is intended for internal usage only.
Agile Keychain in a directory named as "*.agilekeychain". Its houses a copy of data stored in sqlite database: master encryption keys and all your passwords. Keychain is made to be very portable and simple. Indeed, one could use Dropbox sync to update keychain on all devices. In most cases agile keychain is also referred to as a vault.
Let take a look at directory structure:
$ ls ~/Dropbox/1password/1Password.agilekeychain/data/default
-rw-r--r--@ 1 sosedoff staff 6550 Nov 29 2013 .1password.keys
-rw-r--r--@ 1 sosedoff staff 6550 Nov 29 2013 1password.keys
-rw-r--r--@ 1 sosedoff staff 9849 May 28 10:01 contents.js
-rw-r--r--@ 1 sosedoff staff 6014 Nov 29 2013 encryptionKeys.js
-rw-r--r--@ 1 sosedoff staff 645 Apr 12 12:00 02B3B2D9239B4F75B0B76FEE21041A31.1password
-rw-r--r--@ 1 sosedoff staff 1469 Sep 7 2014 05DF594381C440BFA8F3149F9CC1F71F.1password
-rw-r--r--@ 1 sosedoff staff 630 Nov 10 2014 06ADBCB6993A42BCA4B04CEEDCE1988A.1password
-rw-r--r--@ 1 sosedoff staff 607 Dec 2 2013 0792B0BBE0D24AFFBD850F1A97AA4B3E.1password
There are two files .1password.keys and 1password.keys which are exactly the same and hold master encryption keys for the whole keychain in xml plist. format. Another file encryptionKeys.js represents a JSON version of the same encryption keys, which is easier to read and work with.
Lets take a look at encryptionKeys.js
contents:
{
"SL3": "6F08ADB92B284CF5B021ED5411450A59",
"SL5": "830EF6E33D8947648FFF28C4C52863BF",
"list": [
{
"data": "U2FsdGVkX1+arfeNeAr3bjLcYIsOqtF2w98XN6FH7bo...",
"validation": "U2FsdGVkX191D55NSlStJT6NImLPRwCuFcWOc...",
"level": "SL5",
"identifier": "830EF6E33D8947648FFF28C4C52863BF",
"iterations": 83333
},
{
"data": "U2FsdGVkX18ghmg2cVZaTqrhXvJLjN0uI8O7y6Ka9Xcs...",
"validation": "U2FsdGVkX18QMP1Hhi5BK1TLIzfUes1j23hdcs...",
"level": "SL3",
"identifier": "6F08ADB92B284CF5B021ED5411450A59",
"iterations": 83333
}
]
}
SL3
and SL5
properties in the json data represent security level identifiers
used to associate encryption key with a password item. list
property holds the
detailed collection of keys used to encrypt all passwords in the keychain. data
property of a key is a base64-encoded decryption key which is encrypted by a key
derived from your master password using PBKDF2
with number of iterations
. The master password itself is not being stored anywhere
and comes from a user during keychain "unlock". validation
property represents
a copy of decryption key encrypted with itself.
Rest of the keychain directory is filled with *.1password
files. These are actual
secret files with your account details, etc. Example:
{
"uuid": "02B3B2D9239B4F75B0B76FEE21041A31",
"updatedAt": 1428858020,
"locationKey": "example.com",
"securityLevel": "SL5",
"contentsHash": "82b86c8e",
"title": "My Password",
"location": "example.com",
"encrypted": "U2FsdGVkX19DVMD68Wv7dp5m\/IP\/JUMLD++itV\/GWrQ5+...",
"txTimestamp": 1428858020,
"createdAt": 1428857993,
"typeName": "webforms.WebForm"
}
Most of the password file properties are plaintext, such as security level used
to reference master encryption key, and other metadata. What we're interested in is
the encrypted
attribute which represents a password's encrypted JSON payload.
Its content is encrypted with a 1024-byte randomly generated key.
Lets Decrypt
Now that we understand the basics, lets try to decrypt keychain programmatically using Go. Language choice is purely for experimental purpose. Instead of testing code on an existing vault, for safety reasons i created a new vault with a simple master password and then exported it into agile keychain. Keychain only includes one password item with the following:
- Login:
gmail.com
- Password:
mygmailpassword
After the export i grabbed all necessary data form keychain files:
key = U2FsdGVkX18O742tiin24L289EipX0yfOvS24X+XPnS2MRItGW1qQr4Lw5qd6dMYGa7zhbMUY1K/bbO0i7X0gwP/QgxCOHlyCnosq9yH0SIP7uQ8StAE1gxWaY2GM42oCIaKgXS3uvvSrtKZOyJizmgnMcA4KR5fNbfRHQoiZaWpT+2L+eckYeglN/+FN4YuAwgREzydBbzwGrJkDwWGuaHdXVBVRUMNjqJy7lP7gQ8iIVh6dPR1V733cgckPWLY6BaWs4CMrFu+/UOuPSvtnDXGjHCEXzwLjPKNKCvTF2h585Nl3zlMw/G/ZSlg0sjdy0MHOjIMMRjTKidzICNbNZgEKBe7BlW/MUBmaV4Vca+om2I8kcnC/7fBOevmZG810/zWlsPgt6nStroIMFeq1Vmqc4GnFa2GZcpKIpD7QJwAfcTfN0ALBlXgaXzO0/3bZduN2rMhoUygVPGncEcsnMfFKyknbuUXW/RHNxoOdS3k7n18yi6ZoQAeV+AVeKgHCinrGoKmmWHNTyvnZ/AXtkceuLcu99PaZidAErQdTsxxtCAUfPD++2Qs1LKwr/J9z1cnJM7TybBB+J4uNyWVLIV4WhyHGL4SZmCT2Apf0ZbqPWFKaSkelg96S5xQ0+epMxv4r6qsZoIBBJjijboWfl6UTgSzqcaDeQUbajhKTEZl65zqq00y3BG51i6AuLpjkhKNVitc/Xhu1dOT5YEja3wGsSLL2axSqSk2PG80PmprHduTkLArQkeEihED3UhtMVC40CvRcjr17FTxRjkDI2iZfDYWIxOIFDUZ1LPwiJXP3Egoi+HRFSZqQ5Uyv6WaET3a+drQFHHw+bliWYbRodx/qqb8t6ubnox/i9iaQ+eYqRXrKz58uXqBcJBhpa3+uU+Vj92qBBBxcCReZ+X5xYf9Kv8JOmjturFG1rG31qPrFuEKT+6xNSYa8qWP7rk3uKo/SZES03f4fwxDmP1/SxZtVtufWqkvvYM7ml6zwOj6fY2+65+3x2a3A/stAVtYjWLtqJbbzjzj2nSEcXr7+QN3TP0gRotbU+iLwgkrP97FPnj8ri3vmnQgqquvKu5C4dqNo6lwQuB/PaS9MF+vfHJlkDaTbk0SqfiWwjSnGJ4wQvGUCxSP2GUio4HoMmyuBy7gQ/UsU9ijuoVt/Geu3OwTpa8B7mmiWzcd5cwq/kjxJ9l460dhcyvLfoSWCDRVyvQd2kDAPRxqZ3FPKu4RNqOXr5EnqMqCeA3p6kp+copCgQUEEJxduisgYzNxdjgViWYbYzOgepKCq0jqOIrg7Rud3vD152VhbLXORyqmoYVZ3sNWTMpPCdD1mO2zkierl2JZRf78EpYUq6rzdGS9ZXApYSMH0iRIrC9rxHdHiMNPm5WVU6clVIDbcvyhMoGu
password = U2FsdGVkX19rzutpu67A2hGyjdoLGSnWEtTGcfSKK7w4SUATx2y3bB0HdvXpTrODvJjAa73eJUDF+oRMYVyh6k9EIBCW8XI+1aKbtQGNVnJtRoW7xrpkl3TdgC++x0e4UPjB+LBA79LK8gHTr4Ad2Kd06dWTxwpqAgsLU3VI6ZUXvoctUk9ffxCHzZocQBBHnEInovrp+/kUHZxYWZkS6/2wZiY60PH9gYyatenh472mNgS9h0sH3FW38HFVKudhxj4uIiYr+gO3Y6XxTq9th7ykhV4FwpYXL9xbKptE2H8=
iterations = 100000
Workflow
Our objective is to get code to produce the same login and password, that way we know it works. Lets overview all steps before we dig into any code:
- First, convert key (A) and password (B) data from base64 to raw bytes.
- Extract salt (C) from decoded key (A) data.
- Derive a new key by applying PBKDF2 with master password, extracted salt (C) and number of iterations.
- Extract an AES-128 key and initialization vector (IV) from derived key.
- Decrypt original key (A) data with extracted AES key and IV from previous step.
- Extract password salt (D) from decoded password (B) data.
- Derive an AES-128 key and IV from decrypted key (from step 5) and salt (D) using MD5.
- Decrypt password data with extracted AES key and IV from previous step.
Code
Instead of going to review each chunk of code individually, i put together a code snippet that implements decryption mechanisms:
package main
import (
"bytes"
"crypto/md5"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"log"
"strings"
"github.com/spacemonkeygo/openssl"
"golang.org/x/crypto/pbkdf2"
)
func main() {
masterpass := []byte("pa$$w0rd")
encodedKey := `U2FsdGVkX18O742tiin24L289EipX0yfOvS24X+XPnS2MRItGW1qQr4Lw5qd6dMYGa7zhbMUY1K/bbO0i7X0gwP/QgxCOHlyCnosq9yH0SIP7uQ8StAE1gxWaY2GM42oCIaKgXS3uvvSrtKZOyJizmgnMcA4KR5fNbfRHQoiZaWpT+2L+eckYeglN/+FN4YuAwgREzydBbzwGrJkDwWGuaHdXVBVRUMNjqJy7lP7gQ8iIVh6dPR1V733cgckPWLY6BaWs4CMrFu+/UOuPSvtnDXGjHCEXzwLjPKNKCvTF2h585Nl3zlMw/G/ZSlg0sjdy0MHOjIMMRjTKidzICNbNZgEKBe7BlW/MUBmaV4Vca+om2I8kcnC/7fBOevmZG810/zWlsPgt6nStroIMFeq1Vmqc4GnFa2GZcpKIpD7QJwAfcTfN0ALBlXgaXzO0/3bZduN2rMhoUygVPGncEcsnMfFKyknbuUXW/RHNxoOdS3k7n18yi6ZoQAeV+AVeKgHCinrGoKmmWHNTyvnZ/AXtkceuLcu99PaZidAErQdTsxxtCAUfPD++2Qs1LKwr/J9z1cnJM7TybBB+J4uNyWVLIV4WhyHGL4SZmCT2Apf0ZbqPWFKaSkelg96S5xQ0+epMxv4r6qsZoIBBJjijboWfl6UTgSzqcaDeQUbajhKTEZl65zqq00y3BG51i6AuLpjkhKNVitc/Xhu1dOT5YEja3wGsSLL2axSqSk2PG80PmprHduTkLArQkeEihED3UhtMVC40CvRcjr17FTxRjkDI2iZfDYWIxOIFDUZ1LPwiJXP3Egoi+HRFSZqQ5Uyv6WaET3a+drQFHHw+bliWYbRodx/qqb8t6ubnox/i9iaQ+eYqRXrKz58uXqBcJBhpa3+uU+Vj92qBBBxcCReZ+X5xYf9Kv8JOmjturFG1rG31qPrFuEKT+6xNSYa8qWP7rk3uKo/SZES03f4fwxDmP1/SxZtVtufWqkvvYM7ml6zwOj6fY2+65+3x2a3A/stAVtYjWLtqJbbzjzj2nSEcXr7+QN3TP0gRotbU+iLwgkrP97FPnj8ri3vmnQgqquvKu5C4dqNo6lwQuB/PaS9MF+vfHJlkDaTbk0SqfiWwjSnGJ4wQvGUCxSP2GUio4HoMmyuBy7gQ/UsU9ijuoVt/Geu3OwTpa8B7mmiWzcd5cwq/kjxJ9l460dhcyvLfoSWCDRVyvQd2kDAPRxqZ3FPKu4RNqOXr5EnqMqCeA3p6kp+copCgQUEEJxduisgYzNxdjgViWYbYzOgepKCq0jqOIrg7Rud3vD152VhbLXORyqmoYVZ3sNWTMpPCdD1mO2zkierl2JZRf78EpYUq6rzdGS9ZXApYSMH0iRIrC9rxHdHiMNPm5WVU6clVIDbcvyhMoGu`
encodedPassword := `U2FsdGVkX19rzutpu67A2hGyjdoLGSnWEtTGcfSKK7w4SUATx2y3bB0HdvXpTrODvJjAa73eJUDF+oRMYVyh6k9EIBCW8XI+1aKbtQGNVnJtRoW7xrpkl3TdgC++x0e4UPjB+LBA79LK8gHTr4Ad2Kd06dWTxwpqAgsLU3VI6ZUXvoctUk9ffxCHzZocQBBHnEInovrp+/kUHZxYWZkS6/2wZiY60PH9gYyatenh472mNgS9h0sH3FW38HFVKudhxj4uIiYr+gO3Y6XxTq9th7ykhV4FwpYXL9xbKptE2H8=`
iterations := 100000
// First, we need to convert key and password data from base64 to raw bytes using
// helper method used to make sure there are no escape "\" chars in the key.
key, err := base64decode(encodedKey)
if err != nil {
log.Fatalln("key is not base64:", err)
}
password, err := base64decode(encodedPassword)
if err != nil {
log.Fatalln("password is not base64:", err)
}
// Encrypted key data is salted. To determine if key is salted you can compare
// its first 8 bytes "Salted__". The rest 8 bytes are actual salt data.
keySalt := key[8:16]
keyData := key[16:]
// Next step is to get an encryption key and IV by applying PBKDF2 to master password
derivedKey := pbkdf2.Key(masterpass, keySalt, iterations, 32, sha1.New)
// Now we need to extract AES key and IV from newly derived key
aesKey := derivedKey[0:16]
aesIv := derivedKey[16:32]
// After we got AES key and IV we can use it to decrypt our key contents
keyRaw, err := decrypt(keyData, aesKey, aesIv)
if err != nil {
log.Fatalln("unable to decrypt key:", err)
}
// If key decryption is successful, lets use that key to decrypt our password contents.
// First, we would need to get salt and update password data
passwordSalt := password[8:16]
passwordData := password[16:]
// Now, lets derive AES key and IV from password contents
passwordKey, passwordIv := deriveKey(keyRaw, passwordSalt)
// And finally, decrypt password contents
passwordRaw, err := decrypt(passwordData, passwordKey, passwordIv)
if err != nil {
log.Fatalln("unable to decrypt password:", err)
}
// Print out the result
printJson(passwordRaw)
}
func decrypt(data, key, iv []byte) ([]byte, error) {
cipher, err := openssl.GetCipherByName("aes-128-cbc")
if err != nil {
return nil, err
}
ctx, err := openssl.NewDecryptionCipherCtx(cipher, nil, key, iv)
if err != nil {
return nil, err
}
cipherbytes, err := ctx.DecryptUpdate(data)
if err != nil {
return nil, err
}
finalbytes, err := ctx.DecryptFinal()
if err != nil {
return nil, err
}
cipherbytes = append(cipherbytes, finalbytes...)
return cipherbytes, nil
}
func deriveKey(password []byte, salt []byte) (key []byte, iv []byte) {
rounds := 2
data := append(password, salt...)
md5Hashes := make([][]byte, rounds)
sum := md5.Sum(data)
md5Hashes[0] = append([]byte{}, sum[:]...)
for i := 1; i < rounds; i++ {
sum = md5.Sum(append(md5Hashes[i-1], data...))
md5Hashes[i] = append([]byte{}, sum[:]...)
}
return md5Hashes[0], md5Hashes[1]
}
func base64decode(data string) ([]byte, error) {
sanitized := strings.Replace(data, `\`, "", -1)
return base64.StdEncoding.DecodeString(sanitized)
}
func printJson(data []byte) {
var buff bytes.Buffer
if err := json.Indent(&buff, data, "", " "); err == nil {
log.Printf("%s\n", buff.Bytes())
}
}
If you have Go installed, just save the file and run it with:
go run myfile.go
And the result is:
{
"URLs": [
{
"label": "",
"url": "gmail.com"
}
],
"fields": [
{
"value": "mygmailpassword",
"name": "password",
"type": "P",
"designation": "password"
},
{
"value": "",
"name": "username",
"type": "T",
"designation": "username"
}
]
}
It worked, we see that 1Password keeps login data as another json payload with includes more metadata. The reason is pretty simple - you can securely store multiple values in a single login and json is a good fit for that.