Obleevo

Cofre do Obleevo

Também disponível em English · Français · Español · Deutsch · Română · 日本語 · Italiano

O formato de arquivo .obleevo, versão 1

Como um arquivo de cofre do Obleevo é construído, byte a byte, para que qualquer pessoa possa conferir o que o Obleevo faz com a sua frase secreta, ou escrever o próprio programa para abrir um cofre sem o Obleevo. A especificação abaixo está em inglês, para corresponder palavra por palavra ao documento contra o qual o código é testado.

1. Overview

An Obleevo Vault (French: Coffre-fort) is one file, usually named something.obleevo, holding files, notes and passwords encrypted in the browser under a passphrase. The person keeps the file; no server stores it. This document describes version 1 exactly, so that anyone can write an independent reader or check ours. The reference implementation is public/js/vault-core.js; its tests are test/vault.test.mjs.

All integers are unsigned and little-endian. Offsets are in bytes from the start of the file. || is concatenation. SHA-256, HKDF-SHA256 (RFC 5869) and AES-256-GCM (NIST SP 800-38D, 96-bit nonce, 128-bit tag) are the standard algorithms. UTF-8(s) is the UTF-8 encoding of a string.

offset  size  field
     0     8  magic                    "OBLEEVO\0" = 4F 42 4C 45 45 56 4F 00
     8     2  format version           1
    10     1  KDF id                   1 = Argon2id, version 0x13 (RFC 9106)
    11     1  flags                    0 (no flags are defined in version 1)
    12     4  Argon2 memory m, KiB
    16     4  Argon2 iterations t
    20     4  Argon2 parallelism p
    24    16  salt                     random, new on every passphrase change
    40    16  vault id                 random, new at creation and on every re-key
   ---------- end of HEADER (56 bytes)
    56    12  wrap nonce               random
    68    48  wrapped data key         AES-256-GCM(KEK, 32-byte DEK), 32 + 16 tag
   ---------- end of KEY SLOT (offset 116)
   116    16  checksum                 first 16 bytes of SHA-256(bytes 0..115)
   132    12  manifest nonce           random, new on every save
   144     4  L                        length of the manifest ciphertext, incl. tag
   ---------- end of PREFIX (148 bytes)
   148     L  manifest ciphertext      AES-256-GCM
 148+L   ...  file bodies              every file's chunks, in manifest order

The file ends exactly where the last file's last chunk ends. A file that is longer or shorter is refused.

2. Keys

2.1 Passphrase normalisation

The passphrase is a Unicode string. Before use it is normalised:

  1. Apply Unicode NFC.
  2. Let T be the NFC string with every leading and trailing WS character removed, where WS is exactly these four code points and no others: U+0009 (tab), U+000A (line feed), U+000D (carriage return), U+0020 (space). (Not U+00A0, U+3000, U+FEFF or any other space.)
  3. If T consists of six groups of exactly four ASCII characters from 0-9, A-Z, a-z, the groups separated by one or more characters from {- (U+002D)} ∪ WS, and nothing else - in a regular expression, ^[0-9A-Za-z]{4}(?:[-\t\n\r ]+[0-9A-Za-z]{4}){5}$, with no case-insensitive or Unicode flag - this is the shape of a generated passphrase. Then use T with ASCII a-z upper-cased to A-Z, every run of separator characters replaced by one -, every I and L replaced by 1, and every O replaced by 0.
  4. Otherwise use the NFC string from step 1 unchanged, white space included.

The result, as UTF-8, is the Argon2id password P. An empty passphrase is never valid.

2.2 Key-encryption key (KEK)

KEK = Argon2id(P, salt, m = memory KiB, t = iterations, p = parallelism,
               version 0x13, tag length 32, no secret K, no associated data X)

The KEK is used as an AES-256-GCM key.

A reader MUST refuse, before running Argon2id, any file whose parameters are outside these limits (error params):

parameterminimummaximum
memory m19456 KiB (19 MiB)262144 KiB (256 MiB)
iterations t210
parallelism p14, and m >= 8p

The minimum stops a downgraded file from deriving a cheap key; the maximum stops a crafted file from making a device spend more than a few seconds and 256 MiB before it can even say "wrong passphrase". New vaults are written with m = 65536 (64 MiB), t = 3, p = 1. The parameters live in the header so they can be raised by a later save; since the header is authenticated (2.3, 3), editing them in the file makes it impossible to open.

The reference implementation also: tells the caller, before deriving, when a file asks for more than the defaults in any dimension (inspectVault), so the person can be warned; runs Argon2id in a worker that can be cancelled; and never runs a cost above the defaults on a page's main thread.

2.3 Data key (DEK)

The DEK is 32 random bytes, generated when the vault is created and again on every re-key (7.1). A quick passphrase change keeps it.

wrapped data key = AES-256-GCM-Encrypt(key = KEK, nonce = wrap nonce,
                                       plaintext = DEK, AAD = HEADER)

HEADER is bytes 0..55. A wrong passphrase, and any change to the header, make this fail to decrypt.

2.4 Subkeys

manifest key = HKDF-SHA256(IKM = DEK, salt = vault id (16 bytes),
                           info = UTF-8("obleevo vault manifest v1"), L = 32)
file key     = HKDF-SHA256(IKM = DEK, salt = kid (16 bytes),
                           info = UTF-8("obleevo vault file v1"), L = 32)

Each kid is 16 random bytes chosen when that file's body is encrypted (see 5). It is recorded in the manifest as 32 lowercase hex characters.

3. Checksum

Bytes 116..131 are the first 16 bytes of SHA-256 over bytes 0..115.

This is not a security mechanism - anyone can recompute it. It exists so a reader can tell a damaged file (error tampered) from a wrong passphrase (error passphrase): without it, a changed salt or wrapped key would look exactly like a wrong passphrase. Security comes from the AES-GCM tags.

4. Manifest

manifest plaintext = AES-256-GCM-Decrypt(key = manifest key, nonce = manifest
                     nonce, ciphertext = bytes 148 .. 148+L-1, AAD = bytes 0..147)

The AAD is the entire prefix: header, key slot, checksum, manifest nonce and L. Any change to any of them makes the manifest fail to decrypt.

L must be at least 4096 + 16 and at most 64 MiB + 16, and 148 + L must not exceed the file size.

The plaintext is padded:

plaintext = len (4 bytes, LE) || JSON (len bytes, UTF-8) || zero bytes

Its total length is a bucket: the smallest of 4096, 8192, 16384, ... up to 1 MiB that holds 4 + len, or above 1 MiB the next whole multiple of 1 MiB. The padding bytes must be zero. Padding hides the exact number and length of notes and passwords.

The JSON is an object with exactly two members:

{ "v": 1, "items": [ ... ] }

Each item is an object with exactly these members, no others:

kindmembers
fileid, kind, name, type, size, chunks, kid, created, modified
noteid, kind, name, text, created, modified
passwordid, kind, name, username, password, url, notes, created, modified
totpid, kind, name, issuer, account, secret, algorithm, digits, period, created, modified

A reader MUST reject a manifest that breaks any of these rules (error format), even though it decrypted. Note and password contents live only in the manifest, and so does a 2FA secret: it is never written anywhere else in the file.

A totp item is a time-based one-time password generator (RFC 6238): the code at Unix time T seconds is HOTP (RFC 4226, with HMAC-algorithm and digits digits) of the counter floor(T / period) as 8 big-endian bytes, under the decoded secret.

Compatibility. The totp kind was added after the first readers shipped (Obleevo 1.24 and its offline opener). Those readers refuse a manifest containing one with error format, since their table of kinds is closed. The format version stays 1: every vault without a totp item is read by every reader, and a writer that adds one cannot change the version without re-encrypting every file (the version is in VH, 5).

5. File bodies

Each file's plaintext is cut into chunks of 1 MiB (1048576 bytes); the last chunk holds the rest. A file of 0 bytes has exactly one chunk, of 0 bytes. Chunk i (counting from 0) of a file with n chunks is:

nonce = LE64(i) || 00 00 00 00                                  (12 bytes)
VH    = SHA-256(bytes 0..9 || vault id)                         (32 bytes)
AAD   = VH || kid (16 raw bytes) || LE64(i) || last || LE64(n)  (65 bytes)
        where last = 01 if i = n - 1, else 00
chunk = AES-256-GCM-Encrypt(key = file key(kid), nonce, plaintext chunk, AAD)

Each chunk is its plaintext length plus 16 bytes. Files follow one another in manifest order with nothing between them, so the body of the k-th file starts at 148 + L + sum over earlier files of (size + 16 * chunks).

What the AAD buys: a chunk moved to another position (index), dropped or repeated (index and count), cut short at a chunk boundary (count and the last flag), moved to another file (kid, which also changes the key), or moved to another vault (vault id, and a different DEK) no longer authenticates.

VH covers only the parts of the header that a quick passphrase change leaves alone - magic, version and vault id - and not the salt or Argon2 parameters, so a quick change does not require re-encrypting every file. Those fields are still authenticated by the key wrap and the manifest. A re-key (7.1) changes the vault id, and so VH, along with every key.

A file's kid is chosen afresh each time its plaintext is encrypted, never reused for a different encryption: a file key encrypts exactly one sequence of chunks, so no key and nonce pair can occur twice. A writer that copies a file's chunks unchanged from an earlier save keeps its kid.

6. Reading, in order

  1. Fewer than 10 bytes, or bytes 0..7 are not the magic: format.
  2. Version above 1: version. Version 0: format.
  3. Fewer than 148 bytes: tampered.
  4. Checksum mismatch: tampered.
  5. KDF id other than 1, or flags not 0: version.
  6. Parameters outside the limits of 2.2: params. (Argon2id has not run.)
  7. L out of range, or past the end of the file: tampered.
  8. Derive the KEK; unwrap the DEK. Failure: passphrase.
  9. Decrypt the manifest. Failure: tampered. Invalid content: format.
  10. The file size is not exactly 148 + L + all the bodies: tampered.
  11. A chunk that fails to decrypt, when it is read: tampered.

A reader returns no plaintext from a chunk that has not authenticated. When streaming a file it releases the last chunk only after checking that all n chunks were read and the total is exactly size.

7. Writing

7.1 Re-keying

A re-key turns a copy into what is, cryptographically, a new vault holding the same items: a new salt, a new random DEK, a new random vault id, a new manifest key (from the new DEK and id), and every file re-encrypted under a new random kid with the new DEK, bound to the new VH. Nothing of the old copy's ciphertext and no old key is reused. A writer reads each source chunk, authenticates it under the old keys exactly as in 6, and only then encrypts its plaintext into the new copy; a chunk that fails stops the whole write, which must then leave the old file in place.

The reference implementation does this in two steps, each all-or-nothing. changePassphrase(p) (re-key is its default) derives the new keys and switches the open vault to them in one synchronous step at the very end; a cancel or failure before that leaves the vault exactly as it was. File bodies stay in the copy they came from, still readable there under the old keys, until the next save, which re-encrypts each one chunk by chunk as it streams the new copy out. A save that fails or is cancelled leaves the old file untouched (the writable is aborted) and can be tried again. Once the vault is rebased onto the new copy, saves copy chunks again.

What each mode protects. Say an attacker has an old copy and its old passphrase - so, by opening it, the old DEK - and later obtains a newer copy.

quick change (rekey: false)re-key (default)
old passphrase opens the newer copynono
old DEK reads the newer copy's index (notes, passwords, 2FA secrets, file names)yesno
old DEK reads the newer copy's file bodiesyesno
newer copy can be linked to the old one by its vault idyes (same id)no (new id; total size still hints)
the old copies themselvesstill open with the old passphrasestill open with the old passphrase
costArgon2id onlyArgon2id, then the next save reads and re-encrypts every file

Neither mode does anything about copies that already exist: an old copy stays exactly as readable as it was, by anyone holding it and its passphrase. Re-keying protects only what is written after it.

8. What this format does not protect

9. Test vector

test/vault.test.mjs checks Argon2id against the RFC 9106 section 5.3 vector, and every rule above against attacks on real files. A sample vault can be produced with:

import { createVault, generatePassphrase } from './public/js/vault-core.js'
const pass = generatePassphrase()     // weak passphrases are refused ('weak')
const v = await createVault(pass)
v.addNote('hello', 'world')
const blob = await v.save()

Última revisão:

Sobre · Vamos trabalhar juntos · Tutoriais · Por que o Obleevo · Perguntas frequentes · Termos · Privacidade · Acessibilidade · Cookies · Segurança · Modelo de ameaças · Auditorias de segurança · Contato · Novidades

O Obleevo é desenvolvido pela The House of Brendrof. Software proprietário, de uso gratuito.

Voltar ao Obleevo