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 )