Merge pull request #60 from alexbakker/doc-vault

Add documentation for Aegis' security design and vault format
pull/71/head
Michael Schättgen 6 years ago committed by GitHub
commit f3b4433d5b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

@ -0,0 +1,264 @@
# Aegis Vault
Aegis persists the user's tokens to a file. This file is referred to as the
__vault__. Users can configure the app to store the vault in plain text or to
encrypt it with a password.
This document describes Aegis' security design and file format. It's split up
into two parts. First, the cryptographic primitives and use of them for
encryption are discussed. The second section documents the details of the file
format of the vault.
## Security
### Primitives
Two cryptographic primitives were selected for use in Aegis. An Authenticated
Encryption with Associated Data (AEAD) cipher and a Key Derivation Function
(KDF).
#### AEAD
__AES-256__ in __GCM__ mode is used as the AEAD cipher to ensure the
confidentility, integrity and authenticity of the vault contents.
It requires a unique 96-bit nonce for each invocation with the same key.
However, it is not possible to use a monotically increasing counter for this in
this case, because a future use case could involve using the vault on multiple
devices simultaneously, which would almost certainly result in nonce reuse. This
is suboptimal, because 96 bits is not large enough to comfortably generate an
unlimited amount of random numbers without getting collisions at some point
either. As a repeat of the nonce would have catastrophic consequences for the
confidentiality and integrity of the ciphertext, NIST strongly recommends not
exceeding 2<sup>32</sup> invocations when using random nonces with GCM. As such,
the security of the Aegis vault also relies on the assumption that this limit is
never exceeded. In the case of Aegis, this is a reasonable assumption to make,
as it's highly unlikely that a user will ever come close to saving the vault
2<sup>32</sup> times.
_Switching to a nonce misuse-resistant cipher like AES-GCM-SIV or a cipher with
a larger (192 bits) nonce like XChaCha-Poly1305 will be explored in the future._
#### KDF
__scrypt__ is used as the KDF to derive a key from a user-provided password,
with the following parameters:
| Parameter | Value |
| :-------- | :------------- |
| N | 2<sup>15</sup> |
| r | 8 |
| p | 1 |
These are the same parameters as Android itself uses to derive a key for
full-disk encryption. Because of the memory limitations Android apps have, it's
not possible to increase them without running into OOM conditions on most
devices.
_Argon2 is a more modern KDF that provides an advantage over scrypt because it
allows tweaking the memory-hardness parameter and CPU-hardness parameter
separately, whereas scrypt ties those together into one cost parameter (N). As
many applications have started using Argon2 in production, it seems that it has
withstood the test of time. It will be considered as an alternative option to
switch to in the future._
### Encryption
When a vault is first created, a random 256-bit key is generated that is used to
encrypt the contents with AES in GCM mode. This key is referred to as the
__master key__.
Aegis supports unlocking a vault with multiple different credentials. The main
credential is a key derived from a user-provided password. In addition to that,
users can also add a key backed by the Android KeyStore (authorized by the scan
of a fingerprint) as a credential.
#### Slots
Each credential that should be able to encrypt/decrypt the contents of a vault
has its own __slot__. Every slot contains a copy of the master key that is
encrypted with its credential. The process of encrypting a key with another key
is known as __key wrapping__. This allows obtaining the master key by providing
any of the credentials. An important consequence is that the master key is only
as secure as the weakest credential.
This design is similar to and largely inspired by LUKS' key slot system.
#### Integrity
Because of the use of an AEAD for encryption, the vault contents and encrypted
master keys in the slots are checked for integrity and authenticity. The rest of
the file is not.
### Overview
An attempt was made to create a clear overview of the encryption system.
![](diagram.svg)
## Format
The vault is stored in JSON and encoded in UTF-8. The upper-level structure is
shown below:
```json
{
"version": 1,
"header": {},
"db": {}
}
```
It starts with a ``version`` number and a ``header``. If a backwards
incompatible change is introduced to the content format, the version number will
be incremented. The vault contents are stored under ``db``. Its value depends on
wheter the vault is encrypted or not. If it is, the value is a string containing
the Base64 encoded (with padding) ciphertext of the vault contents. Otherwise,
the value is a JSON object.
Full examples of a plain text vault and an encrypted vault are available in the
[testdata](/testdata) folder. There's also a Python script that can decrypt an
Aegis vault given the password: [scripts/decrypt.py](/scripts/decrypt.py).
### Header
The header starts with the list of ``slots``. It also has a ``params`` object
that holds the ``nonce`` and ``tag`` that were produced during encryption,
encoded as a hexadecimal string.
Setting ``slots`` and ``params`` to null indicates that the vault is not
encrypted and Aegis will try to parse it as such.
```json
{
"slots": [],
"params": {
"nonce": "0123456789abcdef01234567",
"tag": "0123456789abcdef0123456789abcdef"
}
}
```
#### Slots
The different slot types are identified with a numerical ID.
| Type | ID |
| :---------- | :--- |
| Raw | 0x00 |
| Fingerprint | 0x01 |
| Password | 0x02 |
##### Raw
This slot type is used for raw AES key credentials. It is not used directly in
the app, but all other slots are based on this slot type, so this section
applies to all of them.
Each slot transforms its credential in a way that it can be used to encrypt the
master key with AES-256 in GCM mode. The ``nonce``, ``tag`` and encrypted
``key`` are encoded as a hexadecimal string and stored together. Slots also have
a unique randomly generated ``UUID`` (version 4).
```json
{
"type": 0,
"uuid": "01234567-89ab-cdef-0123-456789abcdef",
"key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"key_params": {
"nonce": "0123456789abcdef01234567",
"tag": "0123456789abcdef0123456789abcdef"
}
}
```
##### Fingerprint
The structure of the Fingerprint slot is exactly the same as the Raw slot. The
difference is that the wrapper key is backed by the Android KeyStore, whereas
Raw slots don't imply use of a particular storage type.
##### Password
As noted earlier, scrypt is used to derive a 256-bit key from a user-provided
password. A random 256-bit ``salt`` is generated and passed to scrypt to protect
against rainbow table attacks. Its stored along with the ``N``, ``r`` and ``p``
parameters.
```json
{
"type": 1,
"uuid": "01234567-89ab-cdef-0123-456789abcdef",
"key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"key_params": {
"nonce": "0123456789abcdef01234567",
"tag": "0123456789abcdef0123456789abcdef"
},
"n": 32768,
"r": 8,
"p": 1,
"salt": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
```
### Content
The content is a JSON object encoded in UTF-8.
```json
{
"version": 1,
"entries": []
}
```
It has a ``version`` number and a list of ``entries``. If a backwards
incompatible change is introduced to the content format, the version number will
be incremented.
#### Entries
Each entry has a unique randomly generated ``UUID`` (version 4), as well as a
``name`` and ``issuer`` to idenfity the account name and service that the token
is for. Entries can also have an icon. These are JPEG's encoded in Base64 with
padding. The ``info`` object holds information specific to the OTP type. The
``secret`` is encoded in Base32 without padding.
There are a number of supported types:
| Type | ID |
| :------------------ | :------ |
| TOTP | "totp" |
| HOTP | "hotp" |
| Steam | "steam" |
There is no specification available for Steam's OTP algorithm. It's essentially
the same as TOTP, but it uses a different final encoding step. Aegis's
implementation of it can be found in
[crypto/otp/OTP.java](https://github.com/beemdevelopment/Aegis/blob/master/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/OTP.java).
The following algorithms are supported for all OTP types:
| Algorithm | ID |
| :-------- | :------- |
| SHA-1 | "SHA1" |
| SHA-256 | "SHA256" |
| SHA-512 | "SHA512" |
Example of a TOTP entry:
```json
{
"type": "totp",
"uuid": "01234567-89ab-cdef-0123-456789abcdef",
"name": "Bob",
"issuer": "Google",
"icon": null,
"info": {
"secret": "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",
"algo": "SHA1",
"digits": 6,
"period": 30
}
}
```

@ -0,0 +1,93 @@
#!/usr/bin/env python3
# this depends on the 'cryptography' package
# pip install cryptography
# example usage: ./scripts/decrypt.py --input ./testdata/aegis_export.json
# password: test
import argparse
import base64
import getpass
import io
import json
import sys
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
from cryptography.hazmat.backends import default_backend
import cryptography
backend = default_backend()
def die(msg, code=1):
print(msg, file=sys.stderr)
exit(code)
def main():
parser = argparse.ArgumentParser(description="Decrypt an Aegis vault")
parser.add_argument("--input", dest="input", required=True, help="encrypted Aegis vault file")
parser.add_argument("--output", dest="output", default="-", help="output file ('-' for stdout)")
args = parser.parse_args()
# parse the Aegis vault file
with io.open(args.input, "r") as f:
data = json.load(f)
# ask the user for a password
password = getpass.getpass().encode("utf-8")
# extract all password slots from the header
header = data["header"]
slots = [slot for slot in header["slots"] if slot["type"] == 1]
# try the given password on every slot until one succeeds
master_key = None
for slot in slots:
# derive a key from the given password
kdf = Scrypt(
salt=bytes.fromhex(slot["salt"]),
length=32,
n=slot["n"],
r=slot["r"],
p=slot["p"],
backend=backend
)
key = kdf.derive(password)
# try to use the derived key to decrypt the master key
cipher = AESGCM(key)
params = slot["key_params"]
try:
master_key = cipher.decrypt(
nonce=bytes.fromhex(params["nonce"]),
data=bytes.fromhex(slot["key"]) + bytes.fromhex(params["tag"]),
associated_data=None
)
break
except cryptography.exceptions.InvalidTag:
pass
if master_key is None:
die("error: unable to decrypt the master key with the given password")
# decode the base64 vault contents
content = base64.b64decode(data["db"])
# decrypt the vault contents using the master key
params = header["params"]
cipher = AESGCM(master_key)
db = cipher.decrypt(
nonce=bytes.fromhex(params["nonce"]),
data=content + bytes.fromhex(params["tag"]),
associated_data=None
)
db = db.decode("utf-8")
if args.output != "-":
with io.open(args.output, "w") as f:
f.write(db)
else:
print(db)
if __name__ == "__main__":
main()
Loading…
Cancel
Save