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