lock
init
9da2bd635d682128938dd3dcbb8dd5f5ab569118
SM <seb.michalk@gmail.com>
2026-04-20 10:55:51 +0000
Makefile | 25 +++++++++++ README.md | 30 +++++++++++++ armor.nim | 10 +++++ format.nim | 23 ++++++++++ kdf.nim | 11 +++++ lock.1 | 86 +++++++++++++++++++++++++++++++++++++ lock.nim | 142 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ mem.nim | 24 +++++++++++ sodium.nim | 28 ++++++++++++ stream.nim | 20 +++++++++ 10 files changed, 399 insertions(+) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3857f97 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.SUFFIXES: .nim .1 + +BIN = /usr/local/bin +MAN = /usr/local/share/man/man1 + +NIMFLAGS = --mm:arc --panics:on -d:release --opt:size -d:danger + +check: + @pkg-config --exists libsodium || (echo "error: libsodium-dev required" && exit 1) + +lock: check lock.nim + nim c $(NIMFLAGS) --passL:-lsodium -o:$@ lock.nim + +clean: + rm -f lock + +install: lock + install -m 755 lock $(BIN)/lock + install -m 644 lock.1 $(MAN)/lock.1 + +uninstall: + rm -f $(BIN)/lock + rm -f $(MAN)/lock.1 + +.PHONY: check clean install uninstall diff --git a/README.md b/README.md new file mode 100644 index 0000000..bae0754 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +lock - encrypt and decrypt files using libsodium + +REQUIREMENTS + libsodium-dev + +INSTALL + make + make install + +UNINSTALL + make uninstall + +USAGE + lock encrypt <file> + Creates <file>.locked + + lock decrypt <file.locked> + Creates <file> (strips .locked extension) + +FILES + /usr/local/bin/lock + /usr/local/share/man/man1/lock.1 + +DESCRIPTION + Encrypts and decrypts files using XChaCha20-Poly1305 AEAD with + Argon2id key derivation. All secrets (passphrases, keys) are + stored in locked memory and zeroed before deallocation. + + Encrypted files are base64-encoded and saved with a .locked + suffix. diff --git a/armor.nim b/armor.nim new file mode 100644 index 0000000..2c6d656 --- /dev/null +++ b/armor.nim @@ -0,0 +1,10 @@ +import std/base64 + +proc b64enc*(data: string): string = + base64.encode(data) + +proc b64dec*(data: string): string = + try: + result = base64.decode(data) + except: + quit("invalid base64", 1) diff --git a/format.nim b/format.nim new file mode 100644 index 0000000..de9b0c4 --- /dev/null +++ b/format.nim @@ -0,0 +1,23 @@ +type + Header* = object + version*: byte + salt*: array[16, byte] + nonce*: array[24, byte] + +const HDRLEN* = 1 + 16 + 24 + +proc hdrput*(h: Header, outbuf: ptr byte) = + var buf = cast[ptr UncheckedArray[byte]](outbuf) + buf[0] = h.version + for i in 0..<16: + buf[1 + i] = h.salt[i] + for i in 0..<24: + buf[17 + i] = h.nonce[i] + +proc hdrget*(data: ptr byte): Header = + var buf = cast[ptr UncheckedArray[byte]](data) + result.version = buf[0] + for i in 0..<16: + result.salt[i] = buf[1 + i] + for i in 0..<24: + result.nonce[i] = buf[17 + i] diff --git a/kdf.nim b/kdf.nim new file mode 100644 index 0000000..f65336d --- /dev/null +++ b/kdf.nim @@ -0,0 +1,11 @@ +import sodium, mem + +proc kdf*(pass: SecBuf, salt: array[16, byte]): SecBuf = + result = mkbuf(crypto_aead_xchacha20poly1305_ietf_KEYBYTES) + let rc = crypto_pwhash(result.p, result.len.csize_t, pass.p, pass.len.culonglong, + addr salt[0], crypto_pwhash_OPSLIMIT_MODERATE, + crypto_pwhash_MEMLIMIT_MODERATE.csize_t, + crypto_pwhash_ALG_ARGON2ID13) + if rc != 0: + free(result) + quit("key derivation failed", 1) diff --git a/lock.1 b/lock.1 new file mode 100644 index 0000000..c3eaf91 --- /dev/null +++ b/lock.1 @@ -0,0 +1,86 @@ +.Dd 2024-01-01 +.Dt LOCK 1 +.Os +.Sh NAME +.Nm lock +.Nd encrypt and decrypt files using libsodium +.Sh SYNOPSIS +.Nm +.Ar encrypt +.Ar file +.Nm +.Ar decrypt +.Ar file.locked +.Sh DESCRIPTION +.Nm +encrypts and decrypts files using XChaCha20-Poly1305 AEAD with Argon2id key derivation. +.Pp +All secret data (passphrases, derived keys) is stored in locked memory using +.Xr sodium_mlock 3 +and zeroed before deallocation. +.Pp +The encrypted file format is: +.Bl -tag -width Ds +.It Sy version +1 byte (currently 0x01) +.It Sy salt +16 bytes (used for key derivation) +.It Sy nonce +24 bytes (used for XChaCha20) +.It Sy ciphertext +encrypted data with appended Poly1305 tag (16 bytes) +.El +.Pp +The encrypted file is base64-encoded and saved with a +.Pa .locked +extension. +.Sh REQUIREMENTS +.Nm +requires libsodium-dev (or equivalent) to be installed. +.Pp +Example for Debian/Ubuntu: +.Pp +.Dl apt-get install libsodium-dev +.Sh EXAMPLES +Encrypt a file: +.Pp +.Dl $ lock encrypt secret.txt +.Pp +This creates +.Pa secret.txt.locked . +.Pp +Decrypt a file: +.Pp +.Dl $ lock decrypt secret.txt.locked +.Pp +This recreates +.Pa secret.txt . +.Pp +On decryption, the +.Pa .locked +suffix is stripped from the output filename. +.Sh SECURITY NOTES +.Bl -bullet +.It +Passphrases are read directly from +.Pa /dev/tty , +not stdin, to prevent piping or redirection. +.It +Passphrases are not echoed and are verified on encrypt. +.It +All secret data lives in locked, zeroed memory. +.It +Argon2id parameters: opslimit=moderate, memlimit=64 MiB. +.It +On any error, the program terminates without attempting recovery. +.El +.Sh EXIT STATUS +.Ex -std +.Sh SEE ALSO +.Xr libsodium 3 , +.Xr sodium_mlock 3 , +.Xr sodium_memzero 3 , +.Xr crypto_pwhash 3 , +.Xr crypto_aead_xchacha20poly1305_ietf 3 +.Sh AUTHORS +Sebastian Michalk diff --git a/lock.nim b/lock.nim new file mode 100644 index 0000000..5cd53ff --- /dev/null +++ b/lock.nim @@ -0,0 +1,142 @@ +import os, sodium, mem, kdf, stream, format, armor, termios, posix + +const VER = 1 + +proc die(msg: string) = + stderr.writeLine(msg) + quit(1) + +proc hasSuffix(s: string, suf: string): bool = + if s.len < suf.len: + return false + return s[(s.len - suf.len)..s.high] == suf + +proc rdpass(prompt: string): SecBuf = + var tty = open("/dev/tty", fmRead) + if tty.isNil: + die("cannot open /dev/tty") + + var old: Termios + if tcGetAttr(tty.getFileHandle(), addr old) != 0: + tty.close() + die("tcgetattr failed") + + var newt = old + var lflag = cast[ptr cuint](cast[uint](addr newt) + 3 * sizeof(cuint).uint) + lflag[] = lflag[] and (not ECHO.cuint) + + block: + if tcSetAttr(tty.getFileHandle(), TCSAFLUSH, addr newt) != 0: + tty.close() + die("tcsetattr failed") + + stdout.write(prompt) + stdout.flushFile() + + var line = "" + while true: + var c: char + if tty.readBuffer(addr c, 1) != 1: + tty.close() + die("read failed") + if c == '\n': + break + line.add(c) + + result = mkbuf(line.len) + copyMem(result.p, addr line[0], line.len) + + if tcSetAttr(tty.getFileHandle(), TCSANOW, addr old) != 0: + tty.close() + free(result) + die("tcsetattr restore failed") + + tty.close() + echo "" + +proc chkpass(): SecBuf = + let p1 = rdpass("passphrase: ") + let p2 = rdpass("repeat passphrase: ") + if p1.len != p2.len or not equalMem(p1.p, p2.p, p1.len): + free(p1) + free(p2) + die("passphrases do not match") + free(p2) + result = p1 + +proc encfile(path: string) = + let pass = chkpass() + let content = readFile(path) + + var h: Header + h.version = VER + randombytes_buf(addr h.salt[0], 16) + randombytes_buf(addr h.nonce[0], 24) + + let key = kdf(pass, h.salt) + + var buf = newString(HDRLEN + content.len + crypto_aead_xchacha20poly1305_ietf_ABYTES) + hdrput(h, cast[ptr byte](addr buf[0])) + + let clen = stream.enc(key.p, h.nonce, addr content[0], content.len.culonglong, + cast[pointer](addr buf[HDRLEN])) + if clen < 0: + free(key) + die("encryption failed") + + let armored = armor.b64enc(buf[0..(HDRLEN + clen - 1)]) + writeFile(path & ".locked", armored) + + free(key) + +proc decfile(path: string) = + let pass = rdpass("passphrase: ") + let armored = readFile(path) + let buf = armor.b64dec(armored) + + if buf.len < HDRLEN: + free(pass) + die("invalid file format") + + let h = hdrget(cast[ptr byte](addr buf[0])) + if h.version != VER: + free(pass) + die("unsupported version") + + let key = kdf(pass, h.salt) + + var outbuf = newString(buf.len - HDRLEN) + let mlen = stream.dec(key.p, h.nonce, cast[pointer](addr buf[HDRLEN]), + (buf.len - HDRLEN).culonglong, addr outbuf[0]) + if mlen < 0: + free(key) + die("decryption failed (wrong passphrase or corrupted file)") + + writeFile(path[0..(path.high - 7)], outbuf[0..(mlen - 1)]) + + free(key) + +proc main = + if sodium_init() < 0: + die("libsodium init failed") + + if paramCount() < 1: + die("usage: lock encrypt|decrypt file") + + let cmd = paramStr(1) + if paramCount() < 2: + die("usage: lock encrypt|decrypt file") + + let path = paramStr(2) + + if cmd == "encrypt": + encfile(path) + elif cmd == "decrypt": + if not path.hasSuffix(".locked"): + die("file must end with .locked") + decfile(path) + else: + die("usage: lock encrypt|decrypt file") + +when isMainModule: + main() diff --git a/mem.nim b/mem.nim new file mode 100644 index 0000000..c39aaca --- /dev/null +++ b/mem.nim @@ -0,0 +1,24 @@ +import sodium + +type + SecBuf* = object + p*: pointer + len*: int + +proc mkbuf*(len: int): SecBuf = + if len <= 0: + quit("invalid length", 1) + result.p = alloc0(len) + result.len = len + if sodium_mlock(result.p, len.csize_t) != 0: + dealloc(result.p) + quit("mlock failed", 1) + +proc zero*(b: SecBuf) = + sodium_memzero(b.p, b.len.csize_t) + +proc free*(b: SecBuf) = + zero(b) + if sodium_munlock(b.p, b.len.csize_t) != 0: + quit("munlock failed", 1) + dealloc(b.p) diff --git a/sodium.nim b/sodium.nim new file mode 100644 index 0000000..6a102b3 --- /dev/null +++ b/sodium.nim @@ -0,0 +1,28 @@ +proc sodium_init(): cint {.importc, cdecl.} +proc sodium_memzero(p: pointer, len: csize_t) {.importc, cdecl.} +proc sodium_mlock(p: pointer, len: csize_t): cint {.importc, cdecl.} +proc sodium_munlock(p: pointer, len: csize_t): cint {.importc, cdecl.} +proc randombytes_buf(p: pointer, len: csize_t) {.importc, cdecl.} + +export sodium_init, sodium_memzero, sodium_mlock, sodium_munlock, randombytes_buf + +const + crypto_pwhash_ALG_ARGON2ID13* = 2.cint + crypto_aead_xchacha20poly1305_ietf_KEYBYTES* = 32 + crypto_aead_xchacha20poly1305_ietf_NPUBBYTES* = 24 + crypto_aead_xchacha20poly1305_ietf_ABYTES* = 16 + crypto_pwhash_SALTBYTES* = 16 + crypto_pwhash_OPSLIMIT_MODERATE* = 3.culong + crypto_pwhash_MEMLIMIT_MODERATE* = 67108864'u64 + +proc crypto_pwhash(key: pointer, keylen: csize_t, passwd: pointer, passwdlen: culonglong, + salt: pointer, opslimit: culong, memlimit: csize_t, alg: cint): cint {.importc, cdecl.} + +proc crypto_aead_xchacha20poly1305_ietf_encrypt(c: pointer, clenp: ptr culonglong, m: pointer, mlen: culonglong, + ad: pointer, adlen: culonglong, nsec: pointer, npub: pointer, k: pointer): cint {.importc, cdecl.} + +proc crypto_aead_xchacha20poly1305_ietf_decrypt(m: pointer, mlenp: ptr culonglong, nsec: pointer, c: pointer, clen: culonglong, + ad: pointer, adlen: culonglong, npub: pointer, k: pointer): cint {.importc, cdecl.} + +export crypto_pwhash_ALG_ARGON2ID13, crypto_aead_xchacha20poly1305_ietf_KEYBYTES, crypto_aead_xchacha20poly1305_ietf_NPUBBYTES, crypto_aead_xchacha20poly1305_ietf_ABYTES, crypto_pwhash_SALTBYTES, crypto_pwhash_OPSLIMIT_MODERATE, crypto_pwhash_MEMLIMIT_MODERATE +export crypto_pwhash, crypto_aead_xchacha20poly1305_ietf_encrypt, crypto_aead_xchacha20poly1305_ietf_decrypt diff --git a/stream.nim b/stream.nim new file mode 100644 index 0000000..0acf6b5 --- /dev/null +++ b/stream.nim @@ -0,0 +1,20 @@ +import sodium + +type + KeyPtr = pointer + +proc enc*(key: KeyPtr, nonce: array[24, byte], plaintext: pointer, mlen: culonglong, outbuf: pointer): int = + var clen: culonglong + let rc = crypto_aead_xchacha20poly1305_ietf_encrypt(outbuf, addr clen, plaintext, mlen, + nil, 0, nil, addr nonce[0], key) + if rc == 0: + return clen.int + return -1 + +proc dec*(key: KeyPtr, nonce: array[24, byte], ciphertext: pointer, clen: culonglong, outbuf: pointer): int = + var mlen: culonglong + let rc = crypto_aead_xchacha20poly1305_ietf_decrypt(outbuf, addr mlen, nil, ciphertext, clen, + nil, 0, addr nonce[0], key) + if rc == 0: + return mlen.int + return -1