Formatul de fișier .obleevo, versiunea 1
Cum este construit un fișier de seif Obleevo, octet cu octet, pentru ca oricine să poată verifica ce face Obleevo cu fraza lui de acces sau să-și scrie propriul program care deschide un seif fără Obleevo. Specificația de mai jos este în engleză, ca să corespundă cuvânt cu cuvânt documentului după care este testat codul.
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:
- Apply Unicode NFC.
- 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.)
- 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 ASCIIa-zupper-cased toA-Z, every run of separator characters replaced by one-, everyIandLreplaced by1, and everyOreplaced by0. - 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):
| parameter | minimum | maximum |
|---|---|---|
| memory m | 19456 KiB (19 MiB) | 262144 KiB (256 MiB) |
| iterations t | 2 | 10 |
| parallelism p | 1 | 4, 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:
| kind | members |
|---|---|
file | id, kind, name, type, size, chunks, kid, created, modified |
note | id, kind, name, text, created, modified |
password | id, kind, name, username, password, url, notes, created, modified |
totp | id, kind, name, issuer, account, secret, algorithm, digits, period, created, modified |
id: 32 lowercase hex characters (16 random bytes), unique in the vault.name: the file name, or the note's or password's title; string, at most 4096 UTF-16 code units.type: the file's media type, string, at most 255 code units (may be empty).size: the file's length in bytes, a non-negative integer below 2^53.chunks:max(1, ceil(size / 1048576)), and below 2^32.kid: 32 lowercase hex characters, unique among the vault's files.text(note),notes(password): strings of at most 8 Mi code units.username,password,url: strings of at most 65536 code units.issuer,account(totp): strings of at most 4096 code units.secret(totp): the 2FA shared secret in RFC 4648 base32, in its one canonical spelling: upper-caseA-Zand2-7only, no spaces, no=padding; its length mod 8 is not 1, 3 or 6, and it decodes (ignoring the bits after the last whole byte) to 10 to 128 bytes.algorithm(totp): exactly"SHA1","SHA256"or"SHA512".digits(totp): the number 6 or 8.period(totp): the number 30 or 60.created,modified: milliseconds since 1970-01-01 UTC, finite, >= 0.
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
- Fewer than 10 bytes, or bytes 0..7 are not the magic:
format. - Version above 1:
version. Version 0:format. - Fewer than 148 bytes:
tampered. - Checksum mismatch:
tampered. - KDF id other than 1, or flags not 0:
version. - Parameters outside the limits of 2.2:
params. (Argon2id has not run.) Lout of range, or past the end of the file:tampered.- Derive the KEK; unwrap the DEK. Failure:
passphrase. - Decrypt the manifest. Failure:
tampered. Invalid content:format. - The file size is not exactly 148 + L + all the bodies:
tampered. - 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
- Generate the DEK, salt, vault id and every nonce and kid with a cryptographically secure random generator (
crypto.getRandomValues). - Every save writes a fresh manifest nonce.
- A quick passphrase change writes a new salt, a new wrap nonce, and (optionally) new Argon2 parameters, and re-wraps the same DEK.
- A re-key (7.1) writes all of that with a new DEK and a new vault id, and every file body re-encrypted.
- Copying a file's chunks from an earlier save without re-encrypting them is allowed and expected; the reference implementation verifies each copied chunk's tag first, so a damaged source cannot produce a new file that will not open.
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 copy | no | no |
| old DEK reads the newer copy's index (notes, passwords, 2FA secrets, file names) | yes | no |
| old DEK reads the newer copy's file bodies | yes | no |
| newer copy can be linked to the old one by its vault id | yes (same id) | no (new id; total size still hints) |
| the old copies themselves | still open with the old passphrase | still open with the old passphrase |
| cost | Argon2id only | Argon2id, 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
- Rollback. An older copy of the same vault opens with the same passphrase; nothing in the file says it is not the latest. Keep the file somewhere with history you trust.
- A leaked old passphrase, after a quick change. A quick change re-wraps the data key but keeps it: anyone who opened an old copy with the old passphrase holds the DEK, and can read the index and file bodies of later copies if they obtain them. A re-key (7.1, the reference implementation's default) closes that for every copy written after it, and does nothing for copies written before.
- Sizes. The total length of the file reveals roughly the total size of the files in it (each file adds 16 bytes per MiB of overhead). The number and sizes of notes and passwords are hidden up to the manifest's bucket.
- The device. Encryption cannot protect a vault that is open in a compromised browser or on a compromised machine.
- Weak passphrases. Argon2id makes each guess expensive, not impossible. The reference implementation refuses to create a vault, or change its passphrase, with a passphrase its estimator does not rate at 70 bits and 12 characters or more (error
weak). The estimator is a deliberately pessimistic heuristic, not a measurement, and the file format itself cannot enforce anything: another writer could use any passphrase.
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()
Ultima revizuire:
Despre · Colaborează cu noi · Tutoriale · De ce asta · Întrebări frecvente · Termeni · Confidențialitate · Accesibilitate · Cookie-uri · Securitate · Model de amenințări · Audituri de securitate · Contact · Noutăți
Obleevo este dezvoltat de The House of Brendrof. Software proprietar, gratuit de utilizat.