Repository navigation
syc encryption
Status: written and read by syc since v0.5.0; provisional, not frozen (an outside review of the design may still change it). Test vectors (enc-* in syc-vectors.zip) and the reference reader tools/syc_ref.py, which decrypts with libsodium and hashlib, not with syc's code. Encrypted archives are archive format 3 and encrypted streams stream version 5; everything not said here is as in syc archive format (format 2) and syc stream format (version 4), which do not change. Little-endian throughout. ‖ is concatenation.
Against someone who has the file but not the password:
-
Hidden: file contents. In the default mode also the index: names, sizes, dates, attributes, the directory tree, which fragments repeat. With
--visible-namesthe index stays readable (syc landsyc iwork without the password) and only the data is hidden. - Detected (exit 3): any change to the data, the index or the key record; a block moved to another place, version or archive; a stream cut at a block boundary.
- Not hidden: the number of versions; each block's sizes (so roughly how much data each version added); the file's total size.
- Not detected: an archive cut back to the end of an earlier version reads as an archive with fewer versions (rollback). Keep a copy of the archive's size, or of its last commit, if that matters.
- Not a defence against: a weak password (Argon2id only slows guessing down), a compromised computer, or someone who saw the password.
The code is syc's own (no external library); the algorithms are standard and every primitive is checked against the RFCs' test vectors and against independent implementations (libsodium through PyNaCl, OpenSSL, Python's hashlib). An outside review is welcome before relying on it.
| password → key | Argon2id version 1.3 (RFC 9106), output 32 bytes, no secret, no associated data |
| encryption | XChaCha20-Poly1305 (RFC 8439's ChaCha20-Poly1305 AEAD with HChaCha20's 24-byte nonce, draft-irtf-cfrg-xchacha), tag 16 bytes |
| subkeys, fragment ids | BLAKE2s-256 keyed (RFC 7693, key 32 bytes) |
| randomness | the system's CSPRNG (Linux getrandom, Windows BCryptGenRandom) |
- K, the data key: 32 random bytes per archive or stream, made when it is created. Never derived from the password: the password only unwraps it, so changing the password rewrites ~100 bytes and nothing else.
-
Subkeys:
Kx = BLAKE2s-256(key = K, message = label)with the ASCII labelssyc aead,syc fragment,syc cdc:-
K_aeadencrypts the blocks and the index; -
K_fragmentkeys the fragments' hashes: a fragment's identity isBLAKE2s-256(key = K_fragment, message = fragment), so a readable index (--visible-names) does not reveal whether a known file is in the archive; deduplication works as before inside the archive; -
K_cdcseeds the cutter: the gear table is made as in format 2 but from the stateu64(K_cdc[0..8])instead of 0x5359434143444331, so where fragments end does not depend on the contents alone.
-
- Password: its UTF-8 bytes as given (no normalization, no trailing newline), 1 to 1024 bytes.
Right after the header of an archive (8 bytes) or of a stream (7 bytes):
keyrec := "SYCK" flags:u8 reserved:u8[3] slot[2] 216 bytes
slot := gen:u32 kdf:u8 lanes:u8 reserved:u16 memKiB:u32 passes:u32 104 bytes
salt[16] nonce[24] wrapped[48]
-
flagsbit 0: the archive's index is encrypted (the default mode); 0 with--visible-names, and always 0 in a stream. Other bits andreservedare zero. - A slot with
gen = 0is empty (all its bytes zero). A reader tries the non-empty slots, the highergenfirst, and takes the first that unwraps. -
kdf = 1(Argon2id v1.3);lanes= Argon2's p (1–16),memKiB= m (8·p .. 4 194 304, i.e. up to 4 GiB),passes= t (1–256);reservedzero. syc writes p = 1, m = 65536 (64 MiB,--kdf-memchanges it), t = 3. -
KEK = Argon2id(password, salt, t, m, p, 32 bytes);wrapped = XChaCha20-Poly1305-Encrypt(key = KEK, nonce, plaintext = K, AAD)(32 bytes of ciphertext, then the 16-byte tag), whereAAD= every byte of the file before the key record (the header) ‖ the key record's first 8 bytes ‖ the slot's first 16 bytes. The header, the flags and the Argon2 parameters cannot change without the unwrap failing. - A new archive or stream fills slot 0 with
gen = 1. Changing the password (syc k, since v0.5.3) opens a slot with the current password, writes the other slot with the same data keyKatgen= (the higher of the twogens) + 1, flushes the file to the disk, then zeroes the opened slot and flushes again: if interrupted, the old password or the new one opens the file, never neither. Nothing else in the file changes. The new slot keeps the opened one's Argon2id parameters unless--kdf-memsets the memory. BecauseKdoes not change, someone who learned it (or kept a copy of the file) while knowing the old password can still read the data: changing the password is not revoking access. - No slot unwraps: "wrong password, or a damaged key record" (exit 3).
header := "SYCA" 3 cdcMinLog cdcAvgLog cdcMaxLog keyrec 8 + 216 bytes
block := rawLen:u32 packed:u32 crc32:u32 (= 0) nonce[24] ciphertext[payLen] tag[16]
index := "SYCI" rawLen:u32 packed:u32 crc32:u32 (= 0) nonce[24] ciphertext[payLen] tag[16] (flags bit 0 = 1)
| as in format 2, with its crc32 (flags bit 0 = 0)
-
vhead as in format 2 (20 bytes), with
time = 0: the time is in the index. -
Blocks: the payload (stored or a sylz block, as
packedsays,payLen = packed & 0x7FFFFFFF) is encrypted withK_aeadand a random nonce;AAD = "SYCB" ‖ vnum:u32 ‖ k:u32 ‖ rawLen:u32 ‖ packed:u32, wherekis the block's position in its version (0, 1, …). The tag replaces the CRC, which is zero (a CRC of the plaintext would let a guess be confirmed). A block's size inblockinfo.bytesis12 + 24 + payLen + 16. -
Index: encrypted the same way with
AAD = "SYCI" ‖ vnum:u32 ‖ rawLen:u32 ‖ packed:u32. Its plaintext (encrypted or not) istime:i64 ‖ indexdata,indexdataas in format 2, whose fragment hashes are the keyed ids above. -
endandcommitas in format 2. Recovery and appending as in format 2; appending needs the password and keeps the archive's mode.
stream := "SYZ0" 5 codec:u8 level:u8 keyrec block* end
block := rawLen:u32 packed:u32 crc32:u32 (= 0) nonce[24] ciphertext[payLen] tag[16]
end := 0:u32 nonce[24] tag[16]
- Blocks as in an archive, with
AAD = "SYZB" ‖ k:u64 ‖ rawLen:u32 ‖ packed:u32,kthe block's position from 0. -
endis the encryption of nothing withAAD = "SYZE" ‖ n:u64,nthe number of blocks: a stream cut at a block boundary, or with blocks added, is refused. Nothing followsend.
-
syc a -p ARCHIVE PATH...,syc c -p FILE: create encrypted (default: index too;--visible-nameskeeps it readable;--kdf-mem MIB). - The password comes from, in order:
-pPASSWORD/--password=PASSWORD(visible to other users in the process list: avoid it),--password-file FILE(its first line), the environment variableSYC_PASSWORD, else the terminal (without echo; twice when creating). Reading an encrypted file needs no-p: the same sources, or the terminal. - Wrong password: exit 3. No password and no terminal: exit 2.
- Speed (silesia, 212 MB, level 1): Argon2id at 64 MiB takes ~0.24 s, once per command; the blocks are sealed and opened on the worker threads (XChaCha20-Poly1305 ~470 MB/s a thread on x86-64).
syc a -t 40.73 → 0.93 s,syc x -t 40.37 → 0.63 s,syc t -t 40.32 → 0.48 s.