pdb

Simple password manager
Log | Files | Refs

commit 22ef6c142d20e2466116e2f74d94f12c12caf986
parent 9418d82ce62211cc71609747e49ff013c92e5371
Author: Michael Savage <mikejsavage@gmail.com>
Date:   Tue, 10 Feb 2015 21:46:25 +0000

Rewrite!

Diffstat:
.gitignore | 1+
Makefile | 6++++++
README.md | 37++++++++++++++++++++-----------------
merge.lua | 46++++++++++++++++++++++++++++++++++++++++++++++
pdb | 253-------------------------------------------------------------------------------
src/actions.lua | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main.lua | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
update-old-db.lua | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 448 insertions(+), 270 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1 +1,2 @@ .tags +pdb diff --git a/Makefile b/Makefile @@ -0,0 +1,6 @@ +pdb: src/*.lua + lua merge.lua src main.lua > pdb + chmod +x pdb + +clean: + rm -f pdb diff --git a/README.md b/README.md @@ -4,9 +4,9 @@ An encrypted password store with dmenu integration. Security -------- -It uses AES-256 in CTR mode to encrypt your database, which is fine and -generates your 256 bit secret key from `/dev/random` and IVs from -urandom, which is also fine. +pdb uses lua-symmetric, which uses libsodium's secretbox to secure your +passwords. It also uses lua-arc4random, which uses `arc4random` pulled +from LibreSSL for generating passwords. It prompts you for your password when you are adding it rather than passing it as a command line argument so people can't grab it from `ps`, @@ -17,8 +17,20 @@ shoulder can obviously see what you type. Requirements ------------ -OpenSSL, lua, luafilesystem, luacrypto, lua-cjson for pdb -xdotool, dmenu for pdbmenu +[arc4]: https://github.com/mikejsavage/lua-arc4random +[symmetric]: https://github.com/mikejsavage/lua-symmetric + +lua, [lua-arc4random][arc4], [lua-symmetric][symmetric], lua-cjson +Additionally: xdotool, dmenu for pdbmenu + + +Upgrading +--------- + +As of 10th Feb 2015 (commit `xxx`), pdb uses a new database format. I +have included a utility to update an existing password database, which +you can run with `lua update-old-db.lua`. Note that it also generates a +new secret key. Usage @@ -28,27 +40,18 @@ pdb requires you to put a shared secret on each computer you want to use the database on, but the database itself can be given to entities you don't trust (Dropbox, etc) without revealing your passwords. -You need to generate a private key for encrypting your password database -with `pdb genkey`. This should be copied manually between computers you -want to keep the database on and not given to anyone else. - Initialise the database on one of your machines with `pdb init`. You can then start playing with it (`pdb add`, `pdb list`, etc. run `pdb` by itself for a full list). An example session: - $ pdb genkey - Generating your private key. Go do something else while we gather entropy. - >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> - Done! You should chmod 700 ~/.pdb/key - $ pdb init - Database initialised. + $ pdb init + Initialized empty password db in /home/mike/.pdb/ + You should chmod 600 /home/mike/.pdb/key2 $ pdb add test Enter a password for test: fdsa $ pdb gen test2 - Generating a password for test2. Go do something else while we gather entropy. - >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> $ pdb list test test2 diff --git a/merge.lua b/merge.lua @@ -0,0 +1,46 @@ +if not arg[ 1 ] or not arg[ 2 ] then + print( arg[ 0 ] .. " <source directory> <path to main>" ) + os.exit( 1 ) +end + +local lfs = require( "lfs" ) + +local merged = { "#! /usr/bin/lua" } + +local root = arg[ 1 ] +local main = arg[ 2 ] + +local function addDir( rel ) + for file in lfs.dir( root .. "/" .. rel ) do + if file ~= "." and file ~= ".." then + local full = root .. "/" .. rel .. file + local attr = lfs.attributes( full ) + + if attr.mode == "directory" then + addDir( rel .. file .. "/" ) + elseif file:match( "%.lua$" ) and ( rel ~= "" or file ~= main ) then + local f = io.open( full, "r" ) + local contents = f:read( "*all" ) + f:close() + + table.insert( merged, ( [[ + package.preload[ "%s" ] = function( ... ) + %s + end]] ):format( + ( rel .. file ):gsub( "%.lua$", "" ):gsub( "/", "." ), + contents + ) + ) + end + end + end +end + +addDir( "" ) + +local f = io.open( root .. "/" .. main, "r" ) +local contents = f:read( "*all" ) +f:close() + +print( table.concat( merged, "\n" ) ) +print( contents ) diff --git a/pdb b/pdb @@ -1,253 +0,0 @@ -#! /usr/bin/lua - --- libs -local crypto = require( "crypto" ) -local lfs = require( "lfs" ) -local json = require( "cjson.safe" ) - --- config -local Cipher = "aes-256-ctr" -local Hash = "sha256" -local HMAC = "sha256" - -local KeyLength = 32 -local IVLength = 16 -local HMACLength = 32 - -local SplitCipherTextPattern = "^(" .. string.rep( ".", IVLength ) .. ")(.+)(" .. string.rep( ".", HMACLength ) .. ")$" - --- consts -local Help = - "Usage: " .. arg[ 0 ] .. " <command>\n" - .. "where <command> is one of the following:\n" - .. "\n" - .. "genkey - generate a private key for encrypting your passwords\n" - .. "init - creates an initial empty database\n" - .. "add <name> - prompts you to enter a password for <name>\n" - .. "get <name> - prints the password stored under <name>\n" - .. "delete <name> - deleted the password stored under <name>\n" - .. "list - lists the names of all stored passwords\n" - .. "touch - decrypts, sanity checks and reencrypts the database\n" - .. "gen <name> [length] [pattern] - generates a password for <name>. [pattern] is a Lua pattern\n" - .. " examples:\n" - .. " gen test - 32 characters, alphanumeric/puntuation/spaces\n" - .. " gen test 16 - 16 characters, alphanumeric/puntuation/spaces\n" - .. " gen test 10 %%d - 10 characters, numbers only\n" - .. " gen test \"%%l \" - 32 characters, lowercase/spaces\n" - -local commands = { "genkey", "init", "add", "get", "delete", "list", "touch", "gen" } - -for _, command in ipairs( commands ) do - commands[ command ] = true -end - -local dir = os.getenv( "HOME" ) .. "/.pdb/" -local paths = { db = dir .. "db", key = dir .. "key" } - -local random = assert( io.open( "/dev/random", "r" ) ) -local urandom = assert( io.open( "/dev/urandom", "r" ) ) - --- we might need this later -local passwordToAdd - --- some helpers -local function check( condition, form, ... ) - if not condition then - io.stdout:write( form:format( ... ) .. "\n" ) - - os.exit( 1 ) - end -end - -local function loadKey() - local file = assert( io.open( paths.key, "r" ) ) - - local key = file:read( "*all" ) - assert( key:len() == KeyLength, "bad key file" ) - - assert( file:close() ) - - return key -end - -local function loadDB( key ) - local file = assert( io.open( paths.db, "r" ) ) - - local contents = assert( file:read( "*all" ) ) - assert( file:close() ) - - local iv, c, hmac = contents:match( SplitCipherTextPattern ) - assert( iv, "Corrupt DB" ) - - local key2 = crypto.digest( Hash, key ) - assert( hmac == crypto.hmac.digest( HMAC, c, key2, true ), "Corrupt DB" ) - - local m = crypto.decrypt( Cipher, c, key, iv ) - - return assert( json.decode( m ) ) -end - -local function writeDB( db, key ) - local iv = assert( urandom:read( IVLength ) ) - - local m = assert( json.encode( db ) ) - local c = crypto.encrypt( Cipher, m, key, iv ) - - local key2 = crypto.digest( Hash, key ) - local hmac = crypto.hmac.digest( HMAC, c, key2, true ) - - local file = assert( io.open( paths.db, "w" ) ) - assert( file:write( iv .. c .. hmac ) ) - assert( file:close() ) -end - -local function genkey() - local rfile = io.open( paths.key, "r" ) - check( not rfile, "You already have a private key." ) - - print( "Generating your private key. Go do something else while we gather entropy." ) - - local key = "" - - while key:len() < KeyLength do - key = key .. assert( random:read( 1 ) ) - - io.write( "\r" ) - for i = 1, 32 do - io.write( i <= key:len() and ">" or "-" ) - end - io.flush() - end - - print( "\a" ) - - local wfile = assert( io.open( dir .. "key", "w" ) ) - assert( wfile:write( key ) ) - assert( wfile:close() ) - - print( "Done! You should chmod 700 ~/.pdb/key" ) - - os.exit( 0 ) -end - --- real code starts here - -check( arg[ 1 ] and commands[ arg[ 1 ] ], Help ) - -lfs.mkdir( dir ) - -if arg[ 1 ] == "genkey" then - local ok, err = pcall( genkey ) - - check( ok, "genkey failed: %s", err ) - - os.exit( 0 ); -end - -local ok_key, key = pcall( loadKey ) -check( ok_key, "Couldn't private key: %s", key ) - -if arg[ 1 ] == "init" then - local file = io.open( paths.db, "r" ) - check( not file, "You already have a password database." ) - - local ok, err_write = pcall( writeDB, { }, key ) - check( ok, "Couldn't write database: %s", err_write ) - - print( "Database initialised." ) - - os.exit( 0 ) -end - -if arg[ 1 ] == "add" then - check( arg[ 2 ], "add needs a password name." ) - - io.stdout:write( "Enter a password for " .. arg[ 2 ] .. ": " ) - io.stdout:flush() - - passwordToAdd = assert( io.stdin:read( "*l" ) ) - - check( passwordToAdd and passwordToAdd:len() > 0, "Nevermind." ) -end - -local ok_load, db = pcall( loadDB, key ) -check( ok_load, "Couldn't load database: %s", db ) - -if arg[ 1 ] == "get" then - check( arg[ 2 ], "get needs a password name." ) - - if db[ arg[ 2 ] ] then - print( db[ arg[ 2 ] ] ) - else - os.exit( 1 ) - end -elseif arg[ 1 ] == "add" then - check( not db[ arg[ 2 ] ], "%s is already in the database", arg[ 2 ] ) - - db[ arg[ 2 ] ] = passwordToAdd -elseif arg[ 1 ] == "delete" then - check( arg[ 2 ], "delete needs a password name." ) - - db[ arg[ 2 ] ] = nil -elseif arg[ 1 ] == "list" then - local passwords = { } - - for k in pairs( db ) do - table.insert( passwords, k ) - end - table.sort( passwords ) - - for _, k in ipairs( passwords ) do - print( k ) - end -elseif arg[ 1 ] == "gen" then - check( not db[ arg[ 2 ] ], "%s is already in the database", arg[ 2 ] ) - - local genLength = tonumber( arg[ 3 ] ) or 32 - local genPattern = "[" .. ( ( ( arg[ 3 ] and not tonumber( arg[ 3 ] ) ) and arg[ 3 ] ) or arg[ 4 ] or "%w%p " ) .. "]" - - -- matches is a list of characters that match genPattern - -- chars is matches repeated to best fill 256 chars to - -- accelerate generation of passwords from small charsets - - local matches = { } - local chars = { } - - for i = 0, 255 do - if string.char( i ):match( genPattern ) then - table.insert( matches, string.char( i ) ) - end - end - - for i = 1, math.floor( 256 / #matches ) * #matches do - chars[ string.char( i - 1 ) ] = matches[ ( ( i - 1 ) % #matches ) + 1 ] - end - - print( "Generating a password for " .. arg[ 2 ] .. ". Go do something else while we gather entropy." ) - - local password = "" - - while password:len() < genLength do - local c = random:read( 1 ) - - if chars[ c ] then - password = password .. chars[ c ] - end - - io.write( "\r" ) - for i = 1, genLength do - io.write( i <= password:len() and ">" or "-" ) - end - io.flush() - end - - print( "\a" ) - - db[ arg[ 2 ] ] = password -end - -local ok_write, err_write = pcall( writeDB, db, key ) -check( ok_write, "Couldn't write database: %s", err_write ) - -assert( random:close() ) -assert( urandom:close() ) diff --git a/src/actions.lua b/src/actions.lua @@ -0,0 +1,78 @@ +local arc4 = require( "arc4random" ) + +local _M = { } + +function _M.get( db, name ) + if not db[ name ] then + return "No such password." + end + + io.stdout:write( db[ name ] ) +end + +function _M.add( db, name ) + if db[ name ] then + return "That password is already in the DB." + end + + io.stdout:write( "Enter a password for " .. name .. ": " ) + io.stdout:flush() + + local password = assert( io.stdin:read( "*l" ) ) -- TODO + + db[ name ] = password +end + +function _M.delete( db, name ) + if not db[ name ] then + return "No such password." + end + + db[ name ] = nil +end + +function _M.list( db ) + local names = { } + + for name in pairs( db ) do + table.insert( names, name ) + end + table.sort( names ) + + for _, name in ipairs( names ) do + print( name ) + end +end + +function _M.touch( db ) +end + +function _M.gen( db, name, length, pattern ) + if db[ name ] then + return "That password is already in the DB." + end + + local allowed_chars = { } + + for i = 0, 255 do + if string.char( i ):match( pattern ) then + table.insert( allowed_chars, string.char( i ) ) + end + end + + if #allowed_chars == 0 then + return "Nothing matches given pattern." + end + + local password = "" + + for i = 1, length do + local c = allowed_chars[ arc4.random( 1, #allowed_chars ) ] + + password = password .. c + end + + db[ name ] = password +end + +return _M diff --git a/src/main.lua b/src/main.lua @@ -0,0 +1,198 @@ +local lfs = require( "lfs" ) +local symmetric = require( "symmetric" ) +local json = require( "cjson.safe" ) + +local actions = require( "actions" ) + +table.unpack = table.unpack or unpack + +local default_length = 32 +local default_pattern = "[%w%p ]" + +local help = + "Usage: " .. arg[ 0 ] .. " <command>\n" + .. "where <command> is one of the following:\n" + .. "\n" + .. "init - create a new key file and empty database\n" + .. "add <name> - prompt you to enter a password for <name>\n" + .. "get <name> - print the password stored under <name>\n" + .. "delete <name> - delete the password stored under <name>\n" + .. "list - list stored passwords\n" + .. "gen <name> [length] [pattern] - generate a password for <name>\n" + .. "[pattern] is a Lua pattern. Some examples:\n" + .. " gen test - 32 characters, alphanumeric/puntuation/spaces\n" + .. " gen test 16 - 16 characters, alphanumeric/puntuation/spaces\n" + .. " gen test 10 %d - 10 characters, numbers only\n" + .. " gen test \"%l \" - 32 characters, lowercase/spaces" + +local dir = os.getenv( "HOME" ) .. "/.pdb/" +local paths = { db = dir .. "db2", key = dir .. "key2" } + +local function load_key() + local file, err = io.open( paths.key, "r" ) + if not file then + io.stderr:write( "Unable to open key file: " .. err .. "\n" ) + io.stderr:write( "You might need to create it with `pdb init`.\n" ) + return os.exit( 1 ) + end + + local key = assert( file:read( "*all" ) ) -- TODO + file:close() + + return key +end + +local function load_db( key ) + local file, err = io.open( paths.db, "r" ) + if not file then + io.stderr:write( "Unable to open DB: " .. err .. "\n" ) + io.stderr:write( "You might need to create it with `pdb init`.\n" ) + return os.exit( 1 ) + end + + local ciphertext = assert( file:read( "*all" ) ) -- TODO + local plaintext = symmetric.decrypt( ciphertext, key ) + if not plaintext then + io.stderr:write( "DB does not decrypt with the given key.\n" ) + return os.exit( 1 ) + end + + local db = json.decode( plaintext ) + if not db then + io.stderr:write( "DB does not appear to be in JSON format.\n" ) + return os.exit( 1 ) + end + + return db +end + +-- TODO: write to db2 and os.rename +local function write_db( db, key ) + local plaintext = assert( json.encode( db ) ) + local ciphertext = symmetric.encrypt( plaintext, key ) + + local file, err = io.open( paths.db, "w" ) + if not file then + io.stderr:write( "Could not open DB for writing: " .. err .. "\n" ) + return os.exit( 1 ) + end + + file:write( ciphertext ) + -- TODO + local ok = assert( file:close() ) +end + +local function write_new_key() + local key = symmetric.key() + + local file, err = io.open( paths.key, "w" ) + if not file then + io.stderr:write( "Unable to open key file for writing: " .. err .. "\n" ) + return os.exit( 1 ) + end + + assert( file:write( key ) ) + assert( file:close() ) + + return key +end + +-- real code starts here + +local commands = { + add = { + args = 1, + syntax = "<name>", + rewrite = true, + }, + get = { + args = 1, + syntax = "<name>", + }, + delete = { + args = 1, + syntax = "<name>", + rewrite = true, + }, + list = { + args = 0, + }, + touch = { + args = 0, + rewrite = true, + }, + gen = { + args = 3, + syntax = "<name> [length] [pattern]", + rewrite = true, + }, +} + +local cmd = arg[ 1 ] +table.remove( arg, 1 ) + +if not cmd then + print( help ) + + return os.exit( 0 ) +end + +if cmd == "init" then + local file_key = io.open( paths.key, "r" ) + local file_db = io.open( paths.db, "r" ) + + if file_key or file_db then + io.stderr:write( "Your key file/DB already exists. Remove them and run init again if you're sure about this.\n" ) + return os.exit( 1 ) + end + + lfs.mkdir( dir ) + + local key = write_new_key() + write_db( { }, key ) + + return os.exit( 0 ) +end + +local key = load_key() +local db = load_db( key ) + +if cmd == "gen" and #arg > 0 then + local length + local pattern + + if #arg == 1 then + length = default_length + pattern = default_pattern + elseif #arg == 3 then + length = tonumber( arg[ 2 ] ) + pattern = "[" .. arg[ 3 ] .. "]" + else + length = tonumber( arg[ 2 ] ) or default_length + pattern = tonumber( arg[ 2 ] ) and default_pattern or arg[ 2 ] + end + + arg[ 2 ] = length + arg[ 3 ] = pattern +end + +if not actions[ cmd ] then + io.stderr:write( help .. "\n" ) + return os.exit( 1 ) +end + +if commands[ cmd ].args ~= #arg then + io.stderr:write( "Usage: " .. arg[ 0 ] .. " " .. cmd .. " " .. ( commands[ cmd ].syntax or "" ) .. "\n" ) + return os.exit( 1 ) +end + +local err = actions[ cmd ]( db, table.unpack( arg ) ) + +if err then + io.stderr:write( err .. "\n" ) + return os.exit( 1 ) +end + +if commands[ cmd ] then + write_db( db, key ) +end diff --git a/update-old-db.lua b/update-old-db.lua @@ -0,0 +1,99 @@ +local crypto = require( "crypto" ) +local symmetric = require( "symmetric" ) +local json = require( "cjson" ) + +local Cipher = "aes-256-ctr" +local Hash = "sha256" +local HMAC = "sha256" + +local KeyLength = 32 +local IVLength = 16 +local HMACLength = 32 + +local SplitCipherTextPattern = "^(" .. string.rep( ".", IVLength ) .. ")(.+)(" .. string.rep( ".", HMACLength ) .. ")$" + +local dir = os.getenv( "HOME" ) .. "/.pdb/" +local paths = { + old_db = dir .. "db", + old_key = dir .. "key", + new_db = dir .. "db2", + new_key = dir .. "key2", +} + +function io.readable( path ) + local file = io.open( path, "r" ) + + if file then + file:close() + return true + end + + return false +end + +local function load_old_key() + local file = assert( io.open( paths.old_key, "r" ) ) + + local key = file:read( "*all" ) + assert( key:len() == KeyLength, "bad key file" ) + + assert( file:close() ) + + return key +end + +local function load_old_db( key ) + local file = assert( io.open( paths.old_db, "r" ) ) + + local contents = assert( file:read( "*all" ) ) + assert( file:close() ) + + local iv, c, hmac = contents:match( SplitCipherTextPattern ) + assert( iv, "Corrupt DB" ) + + local key2 = crypto.digest( Hash, key ) + assert( hmac == crypto.hmac.digest( HMAC, c, key2, true ), "Corrupt DB" ) + + local m = crypto.decrypt( Cipher, c, key, iv ) + + return assert( json.decode( m ) ) +end + +local function write_new_key() + local key = symmetric.key() + + local file, err = io.open( paths.new_key, "w" ) + if not file then + io.stderr:write( "Unable to open key file for writing: " .. err .. "\n" ) + return os.exit( 1 ) + end + + assert( file:write( key ) ) + assert( file:close() ) + + return key +end + +local function write_db( db, key ) + local plaintext = assert( json.encode( db ) ) + local ciphertext = symmetric.encrypt( plaintext, key ) + + local file, err = io.open( paths.new_db, "w" ) + if not file then + io.stderr:write( "Could not open DB for writing: " .. err .. "\n" ) + return os.exit( 1 ) + end + + file:write( ciphertext ) + local ok = assert( file:close() ) +end + +if io.readable( paths.new_key ) or io.readable( paths.new_db ) then + io.stderr:write( "You already have a key/DB in the new format!\n" ) + return os.exit( 1 ) +end + +local db = load_old_db( load_old_key() ) + +local key = write_new_key() +write_db( db, key )