1pass.lua
author Ryan C. Gordon <icculus@icculus.org>
Fri, 23 Jun 2017 17:28:03 -0400
changeset 58 1390348facc7
parent 56 a573346e6f7b
permissions -rw-r--r--
Command line tool that decrypts an OPVault keychain and dumps it to stdout.

To compile: gcc -o opvault opvault.c cJSON.c -lcrypto

Usage: ./opvault </path/to/mykeychain.opvault> <password>

This is just a proof of concept; I'll be recycling this into proper OPVault
support in 1pass later and deleting this tool.

This uses OpenSSL's libcrypto for the math instead of all the homegrown
crypto this project is otherwise using. I'll probably migrate the rest in
this direction, too, since this wasn't as bad as I expected to use and
gets you all the package-manager mojo of automatic bug fixes and security
patches and shared code, etc.

cJSON parses JSON in C. That is from https://github.com/DaveGamble/cJSON

An example OPVault keychain from AgileBits is available here:

https://cache.agilebits.com/security-kb/
JSON = (loadfile "JSON.lua")()
dofile("dumptable.lua")

local basedir = "1Password/1Password.agilekeychain/data/default"  -- !!! FIXME
local password = argv[2]
local items = nil
local faveitems = nil
local keyhookRunning = false
local keyhookGuiMenus = nil


local function runGarbageCollector()
    --local memused = math.floor(collectgarbage("count") * 1024.0)
    --print("Collecting garbage (currently using " .. memused .. " bytes).")
    collectgarbage()
    --local newmemused = math.floor(collectgarbage("count") * 1024.0)
    --print("Now using " .. newmemused .. " bytes (" .. memused - newmemused .. " bytes savings).")
end

local passwordTypeNameMap = {
    ["webforms.WebForm"] = "Logins",
    ["wallet.financial.CreditCard"] = "Credit cards",
    ["passwords.Password"] = "Passwords",
    ["wallet.financial.BankAccountUS"] = "Bank accounts",
    ["wallet.membership.Membership"] = "Memberships",
    ["wallet.government.DriversLicense"] = "Drivers licenses",
    ["system.Tombstone"] = "Dead items",
    ["securenotes.SecureNote"] = "Secure notes",
    ["wallet.government.SsnUS"] = "Social Security Numbers",
    ["wallet.computer.Router"] = "Router passwords",
    -- !!! FIXME: more!
}

local passwordTypeOrdering = {
    "webforms.WebForm",
    "wallet.financial.CreditCard",
    "passwords.Password",
    "wallet.financial.BankAccountUS",
    "wallet.membership.Membership",
    "wallet.government.DriversLicense",
    "wallet.government.SsnUS",
    "securenotes.SecureNote",
    "wallet.computer.Router",
    -- never show "system.Tombstone",
    -- !!! FIXME: more!
}

local function load_json_str(str, desc)
    local retval = JSON:decode(str)
    return retval
end

local function load_json(fname)
    local f = io.open(fname, "rb")
    if (f == nil) then
        return nil
    end

    local str = f:read("*a")
    f:close()

    return load_json_str(str, fname)
end


local keys = {}
local function loadKey(level, password)
    if keys[level] ~= nil then
        return keys[level]
    end

    local keysjson = load_json(basedir .. "/encryptionKeys.js")
    if (keysjson == nil) or (keysjson[level] == nil) then
        return nil
    end

    local identifier = keysjson[level]
    for i,v in ipairs(keysjson.list) do
        if v.identifier == identifier then
			local iterations = v.iterations
            if (iterations == nil) or (iterations < 1000) then
			    iterations = 1000
            end

			local decrypted = decryptUsingPBKDF2(v.data, password, iterations)
			if decrypted == nil then
                return nil
            end

			local validate = decryptBase64UsingKey(v.validation, decrypted)
			if validate ~= decrypted then
                return nil
            end

            keys[level] = decrypted
            return decrypted
        end
    end

    return nil
end

local function getHint()
    local f = io.open(basedir .. "/.password.hint", "r")
    if (f == nil) then
        return
    end

    local str = "(hint is '" .. f:read("*a") .. "')."
    f:close()
    --print(str)
    return str
end


local function loadContents()
    return load_json(basedir .. "/contents.js")
end

local function makeMenu()
    return {}
end

local function appendMenuItem(menu, text, callback)
    local item = {}
    item["text"] = text
    if callback ~= nil then
        item["callback"] = callback
    end
    menu[#menu+1] = item
    return item
end

local function setMenuItemSubmenu(menuitem, submenu)
    menuitem["submenu"] = submenu
end

local function setMenuItemChecked(menuitem, ischecked)
    menuitem["checked"] = ischecked
end


local function build_secret_menuitem(menu, type, str, hidden, transform)
    if str == nil then
        return nil
    end

    local valuestr = str
    if hidden == true then
        valuestr = "*****"
    end
    local text = type .. " " .. valuestr

    local callback = function()
        if transform ~= nil then
            str = transform(str)
        end
        copyToClipboard(str)
        --print("Copied data [" .. str .. "] to clipboard.")
        guiDestroyMenu(keyhookGuiMenus[1])
    end
    return appendMenuItem(menu, text, callback)
end

local function transform_otp(str)
    local algorithm, name, argstr = string.match(str, "^otpauth://(.-)/(.-)?(.+)$")
    if algorithm == nil then algorithm = "" end
    if algorithm ~= "totp" then
        print("FIXME: don't know how to handle One Time Passwords using the '" .. algorithm .. "' algorithm!")
        return "000000"
    end

    local args = {}
    while argstr ~= nil and argstr ~= "" do
        local arg
        local idx = string.find(argstr, "&")
        if idx == nil then
            arg = argstr
            argstr = nil
        else
            arg = string.sub(argstr, 0, idx);
            argstr = string.sub(argstr, idx + 1);
        end

        local key, val = string.match(arg, "^(.-)=(.*)$")
        if (key ~= nil) and (val ~= nil) then
            args[key] = val
        end
    end

    --dumptable("otpauth://" .. algorithm .. "/" .. name, args);

    if args["secret"] == nil then
        print("FIXME: this One Time Password doesn't seem to have a secret key!")
        return "000000"
    end

    local retval = decryptTopt(args["secret"]);
    if retval == nil then
        print("FIXME: failed to generate One Time Password; is the secret key bogus?")
        retval = "000000"
    end
    return retval
end


local secret_menuitem_builders = {}

local function build_secret_menuitem_webform(menu, info, secure)
    local addthis = false
    local username = nil
    local password = nil
    local otp = nil
    local designated_password = nil
    local designated_username = nil
    local email = nil

    if secure.fields == nil then
      print("no secure fields, don't know how to handle this item") 
      return
    end

    for i,v in ipairs(secure.fields) do
        --print(info.name .. ": " .. v.type .. ", " .. v.value)
        local ignored = false
        if (v.value == nil) or (v.value == "") then
            ignored = true
        elseif (v.designation ~= nil) and (v.designation == "password") then
            designated_password = v.value
        elseif (v.designation ~= nil) and (v.designation == "username") then
            designated_username = v.value
        elseif (v.type == "P") then
            password = v.value
        elseif (v.type == "T") then
            username = v.value
        elseif (v.type == "E") then
            email = v.value
        else
            ignored = true
        end

        if not ignored then
            addthis = true
        end
    end

    -- this is probably all wrong.
    if secure.sections ~= nil then
        for i,v in ipairs(secure.sections) do
            if v.fields ~= nil then
                for i2,v2 in ipairs(v.fields) do
                    if (type(v2.v) == "string") and (string.sub(v2.v, 0, 10) == "otpauth://") then
                        otp = v2.v
                        addthis = true
                    end
                end
            end
        end
    end

    if addthis then
        -- designated fields always win out.
        if (designated_username ~= nil) then
            username = designated_username
        end

        if (designated_password ~= nil) then
            password = designated_password
        end

        if (username ~= nil) and (email ~= nil) and (email == username) then
            email = nil
        end

        build_secret_menuitem(menu, "username", username)
        build_secret_menuitem(menu, "email", email)
        build_secret_menuitem(menu, "password", password, true)
        build_secret_menuitem(menu, "otp", otp, true, transform_otp)
    end
end
secret_menuitem_builders["webforms.WebForm"] = build_secret_menuitem_webform


local function build_secret_menuitem_password(menu, info, secure)
    build_secret_menuitem(menu, "password", secure.password, true)
end
secret_menuitem_builders["passwords.Password"] = build_secret_menuitem_password


local function build_secret_menuitem_bankacctus(menu, info, secure)
    -- !!! FIXME: there's more data than this in a generic dictionary.
    build_secret_menuitem(menu, "Account type", secure.accountType)
    build_secret_menuitem(menu, "Routing number", secure.routingNo)
    build_secret_menuitem(menu, "Account number", secure.accountNo)
    build_secret_menuitem(menu, "Bank name", secure.bankName)
    build_secret_menuitem(menu, "Owner", secure.owner)
    build_secret_menuitem(menu, "SWIFT code", secure.swift)
    build_secret_menuitem(menu, "PIN", secure.telephonePin)
end
secret_menuitem_builders["wallet.financial.BankAccountUS"] = build_secret_menuitem_bankacctus


local function build_secret_menuitem_driverslic(menu, info, secure)
    -- !!! FIXME: there's more data for this menuitem than this, in a generic dictionary.

    local birthdate = nil
    if secure.birthdate_yy ~= nil then
        birthdate = secure.birthdate_yy
        if secure.birthdate_mm ~= nil then
            birthdate = birthdate .. "/" .. string.sub("00" .. secure.birthdate_mm, -2)
            if secure.birthdate_dd ~= nil then
                birthdate = birthdate .. "/" .. string.sub("00" .. secure.birthdate_dd, -2)
            end
        end
    end

    local expiredate = nil
    if secure.expiry_date_yy ~= nil then
        expiredate = secure.expiry_date_yy
        if secure.expiry_date_mm ~= nil then
            expiredate = expiredate .. "/" .. string.sub("00" .. secure.expiry_date_mm, -2)
            if secure.expiry_date_dd ~= nil then
                expiredate = expiredate .. "/" .. string.sub("00" .. secure.expiry_date_dd, -2)
            end
        end
    end

    build_secret_menuitem(menu, "License number", secure.number)
    build_secret_menuitem(menu, "Class", secure.class)
    build_secret_menuitem(menu, "Expires", expiredate)
    build_secret_menuitem(menu, "State", secure.state)
    build_secret_menuitem(menu, "Country", secure.country)
    build_secret_menuitem(menu, "Conditions", secure.conditions)
    build_secret_menuitem(menu, "Full name", secure.fullname)
    build_secret_menuitem(menu, "Address", secure.address)
    build_secret_menuitem(menu, "Gender", secure.sex)
    build_secret_menuitem(menu, "Birthdate", birthdate)
    build_secret_menuitem(menu, "Height", secure.height)
end
secret_menuitem_builders["wallet.government.DriversLicense"] = build_secret_menuitem_driverslic


local function build_secret_menuitem_membership(menu, info, secure)
    -- !!! FIXME: there's more data than this in a generic dictionary.
    build_secret_menuitem(menu, "Membership number", secure.membership_no)
end
secret_menuitem_builders["wallet.membership.Membership"] = build_secret_menuitem_membership


local function build_secret_menuitem_creditcard(menu, info, secure)
    -- !!! FIXME: there's more data than this in a generic dictionary.
    local expiredate = secure.expiry_yy .. "/" .. string.sub("00" .. secure.expiry_mm, -2)
    build_secret_menuitem(menu, "Type", secure.type)
    build_secret_menuitem(menu, "CC number", secure.ccnum, true)
    build_secret_menuitem(menu, "CVV", secure.cvv, true)
    build_secret_menuitem(menu, "Expires", expiredate)
    build_secret_menuitem(menu, "Card holder", secure.cardholder)
    build_secret_menuitem(menu, "Bank", secure.bank)
end
secret_menuitem_builders["wallet.financial.CreditCard"] = build_secret_menuitem_creditcard


local function build_secret_menuitem_securenote(menu, info, secure)
    build_secret_menuitem(menu, "Notes", secure.notesPlain, true)
end
secret_menuitem_builders["securenotes.SecureNote"] = build_secret_menuitem_securenote


local function build_secret_menuitem_ssnus(menu, info, secure)
    build_secret_menuitem(menu, "Name", secure.name, false)
    build_secret_menuitem(menu, "SSN", secure.number, true)
end
secret_menuitem_builders["wallet.government.SsnUS"] = build_secret_menuitem_ssnus


local function build_secret_menuitem_router(menu, info, secure)
    build_secret_menuitem(menu, "Name", secure.name, false)
    build_secret_menuitem(menu, "Password", secure.password, true)
end
secret_menuitem_builders["wallet.computer.Router"] = build_secret_menuitem_router


local function build_secret_menuitems(info, menu)
    local metadata = load_json(basedir .. "/" .. info.uuid .. ".1password")
    if (metadata == nil) or (next(metadata) == nil) then  -- the "next" trick tests if table is empty.
        return
    end

    local securityLevel = metadata.securityLevel
    if securityLevel == nil then
        securityLevel = metadata.openContents.securityLevel
    end
    --print("title: " .. metadata.title)
    if securityLevel == nil then
        --print("can't find security level, assuming SL5" .. metadata.title)
        securityLevel = "SL5"
    end

    local plaintext = decryptBase64UsingKey(metadata.encrypted, loadKey(securityLevel, password))
    if plaintext == nil then
        return
    end

    local secure = load_json_str(plaintext, info.uuid)
    if secure == nil then
        return
    end
    --dumptable("secure " .. info.name, secure)

    local menuitem = appendMenuItem(menu, info.name)

    if secret_menuitem_builders[info.type] == nil then
        print("WARNING: don't know how to handle items of type " .. info.type)
        dumptable("secure " .. info.type .. " (" .. info.name .. ")", secure)
        return
    end

    if metadata.faveIndex ~= nil then
        --dumptable("fave metadata " .. info.name, metadata)
        faveitems[metadata.faveIndex] = { info=info, secure=secure }
    end

    local submenu = makeMenu()
    secret_menuitem_builders[info.type](submenu, info, secure)
    setMenuItemSubmenu(menuitem, submenu)
end

local function prepItems()
    items = {}
    local contents = loadContents()
    if contents == nil then
        return false
    end
    for i,v in ipairs(contents) do
        local t = v[2]
        if items[t] == nil then
            items[t] = {}
        end
        local bucket = items[t]
        bucket[#bucket+1] = { uuid=v[1], type=t, name=v[3], url=v[4] }  -- !!! FIXME: there are more fields, don't know what they mean yet.
    end
    return true
end

local passwordUnlockTime = nil

local function lockKeychain()
    -- lose the existing password and key, prompt user again.
    password = argv[2]  -- might be nil, don't reset if on command line.
    keys["SL5"] = nil
    passwordUnlockTime = nil
    setPowermateLED(false)

    -- kill the popup if it exists.
    if (keyhookGuiMenus ~= nil) and (keyhookGuiMenus[1] ~= nil) then
        guiDestroyMenu(keyhookGuiMenus[1])
    end
end

function pumpLua()  -- not local! Called from C!
    -- !!! FIXME: this should lose the key in RAM and turn off the Powermate
    -- !!! FIXME:  LED when the time expires instead of if the time has
    -- !!! FIXME:  expired when the user is trying to get at the keychain.
    if passwordUnlockTime ~= nil then
        local now = os.time()
        local maxTime = (15 * 60)  -- !!! FIXME: don't hardcode.
        if os.difftime(now, passwordUnlockTime) > maxTime then
            lockKeychain()
        end
    end
end

function escapePressed()  -- not local! Called from C!
    if keyhookGuiMenus[1] then
        guiDestroyMenu(keyhookGuiMenus[1])
    end
end


local buildGuiMenuList

local function spawnSubMenu(button, submenu, depth)
    local guimenu = guiCreateSubMenu(button)

    for i = #keyhookGuiMenus, depth, -1 do
        if keyhookGuiMenus[i] then
            --print("Destroying conflicting submenu at depth " .. i)
            guiDestroyMenu(keyhookGuiMenus[i])
            keyhookGuiMenus[i] = nil
        end
    end

    --print("New submenu at depth " .. depth)
    keyhookGuiMenus[depth] = guimenu

    buildGuiMenuList(guimenu, submenu)
    guiShowWindow(guimenu)
end

local function buildGuiMenuItem(guimenu, item)
    local cb = item["callback"]
    if cb == nil then
        local submenu = item["submenu"]
        local depth = #keyhookGuiMenus+1
        cb = function (button)
            return spawnSubMenu(button, submenu, depth)
        end
    end
    guiAddMenuItem(guimenu, item["text"], item["checked"], cb)
end

buildGuiMenuList = function(guimenu, list)
    for i,v in ipairs(list) do
        buildGuiMenuItem(guimenu, v)
    end
end

local function buildSearchResultsMenuCategory(guimenu, menu, str)
    local submenu = menu["submenu"]
    if not submenu then return end

    local name = menu["text"]
    -- !!! FIXME: hacky. We should really list favorites first anyhow.
    if name == "Favorites" then return end

    for i,v in ipairs(submenu) do
        if string.find(string.lower(v["text"]), str, 1, true) ~= nil then
            buildGuiMenuItem(guimenu, v)
        end
    end
end

local function buildSearchResultsMenuList(guimenu, topmenu, str)
    for i,v in ipairs(topmenu) do
        buildSearchResultsMenuCategory(guimenu, v, str)
    end
end

local function searchEntryChanged(guimenu, str, topmenu)
    --print("search changed to '" .. str .. "' ...")
    guiRemoveAllMenuItems(guimenu)
    if str == "" then
        buildGuiMenuList(guimenu, topmenu)
    else
        buildSearchResultsMenuList(guimenu, topmenu, string.lower(str))
    end
    guiShowWindow(guimenu)
end

local function handleMenuDestroyed()
    --print("Destroying main menu...")
    for i,v in ipairs(keyhookGuiMenus) do
        if i > 1 then
            guiDestroyMenu(v)
        end
    end
    keyhookGuiMenus = nil
    keyhookRunning = false

    runGarbageCollector()
end

local function launchGuiMenu(topmenu)
    local guimenu = guiCreateTopLevelMenu("1pass",

        function(guimenu, str) -- search text changed callback
            return searchEntryChanged(guimenu, str, topmenu)
        end,

        function()  -- window destroyed callback
            handleMenuDestroyed()
        end
    )
    keyhookGuiMenus = {}
    keyhookGuiMenus[#keyhookGuiMenus+1] = guimenu
    buildGuiMenuList(guimenu, topmenu)
    guiShowWindow(guimenu)
end

local trustedDisks = {}

local function getTrustedDiskChecksumPath(mntpoint)
    return mntpoint .. "/1pass.dat"
end

local function getTrustedDiskChecksum(mntpoint)
    local f = io.open(getTrustedDiskChecksumPath(mntpoint), "rb")
    if f == nil then
        return nil
    end

    local str = f:read("*a")
    f:close()
    return calcSha256(str)
end

local function choseTrustedDisk(mntpoint)
    if trustedDisks[mntpoint] ~= nil then
        trustedDisks[mntpoint] = nil  -- no longer check existing trusted disk.
    else
        -- !!! FIXME: probably needs a message box if this fails.
        local checksum = getTrustedDiskChecksum(mntpoint)
        -- No checksum file yet? Generate and write out a random string.
        if checksum == nil then
            local f = io.open("/dev/urandom", "rb")
            if f ~= nil then
                local str = f:read(4096)
                f:close()
                if (str ~= nil) and (#str == 4096) then
                    f = io.open(getTrustedDiskChecksumPath(mntpoint), "wb")
                    if f ~= nil then
                        if f:write(str) and f:flush() then
                            checksum = calcSha256(str)
                        end
                        f:close()
                    end
                end
            end
        end
        trustedDisks[mntpoint] = checksum
    end

    -- kill the popup if it exists.
    -- !!! FIXME: put this in its own function, this is a copy/paste from elsewhere.
    if (keyhookGuiMenus ~= nil) and (keyhookGuiMenus[1] ~= nil) then
        guiDestroyMenu(keyhookGuiMenus[1])
    end
end

local function buildTrustedDeviceMenu()
    local menu = makeMenu()
    local disks = getMountedDisks()  -- this is a C function.

    table.sort(disks, function(a, b) return a < b end)
    for i,v in ipairs(disks) do
        local item = appendMenuItem(menu, v, function() choseTrustedDisk(v) end)
        if trustedDisks[v] ~= nil then
            setMenuItemChecked(item, true)
        end
    end

    return menu
end

function keyhookPressed()  -- not local! Called from C!
    --print("keyhookPressed: running==" .. tostring(keyhookRunning))
    if keyhookRunning then
        return
    end

    keyhookRunning = true

    local allowaccess = true;
    for mntpoint,checksum in pairs(trustedDisks) do
        if getTrustedDiskChecksum(mntpoint) ~= checksum then
            allowaccess = false
            break
        end
    end

    if not allowaccess then
        -- !!! FIXME: probably needs a message box if this happens.
        keyhookRunning = false
        return
    end

    while password == nil do
        password = runGuiPasswordPrompt(getHint())
        if password == nil then
            keyhookRunning = false
            return
        end
        if loadKey("SL5", password) == nil then
            password = nil  -- wrong password
            local start = os.time()  -- cook the CPU for three seconds.
            local now = start
            while os.difftime(now, start) < 3 do
                now = os.time()
            end
        else
            passwordUnlockTime = os.time()
            setPowermateLED(true)
        end
    end

    if not prepItems() then
        keyhookRunning = false
        return
    end

    local topmenu = makeMenu()
    local favesmenu = makeMenu()
    local securitymenu = makeMenu()
    faveitems = {}

    setMenuItemSubmenu(appendMenuItem(topmenu, "Favorites"), favesmenu)
    setMenuItemSubmenu(appendMenuItem(topmenu, "Security"), securitymenu)

    appendMenuItem(securitymenu, "Lock keychain now", function() lockKeychain() end)
    setMenuItemSubmenu(appendMenuItem(securitymenu, "Require trusted device"), buildTrustedDeviceMenu())

    for orderi,type in ipairs(passwordTypeOrdering) do
        local bucket = items[type]
        if bucket ~= nil then
            local realname = passwordTypeNameMap[type]
            if realname == nil then
                realname = type
            end
            local menuitem = appendMenuItem(topmenu, realname)
            local submenu = makeMenu()
            table.sort(bucket, function(a, b) return a.name < b.name end)
            for i,v in pairs(bucket) do
                build_secret_menuitems(v, submenu)
            end
            setMenuItemSubmenu(menuitem, submenu)
        else
            --print("no bucket found for item type '" .. type .. "'")
        end
    end
    
    -- This favepairs stuff is obnoxious.
    local function favepairs(t)
        local a = {}
        for n in pairs(t) do table.insert(a, n) end
        table.sort(a)
        local i = 0
        local iter = function()
            i = i + 1
            if a[i] == nil then
                return nil
            else
                return a[i], t[a[i]]
            end
        end
        return iter
    end

    for i,v in favepairs(faveitems) do
        --dumptable("fave " .. i, v)
        local menuitem = appendMenuItem(favesmenu, v.info.name)
        local submenu = makeMenu()
        secret_menuitem_builders[v.info.type](submenu, v.info, v.secure)
        setMenuItemSubmenu(menuitem, submenu)
    end

    favepairs = nil
    faveitems = nil

    launchGuiMenu(topmenu)
end


-- Mainline!

--for i,v in ipairs(argv) do
--    print("argv[" .. i .. "] = " .. v)
--end

-- !!! FIXME: message box, exit if basedir is wack.
local f = io.open(basedir .. "/contents.js", "rb")
if f == nil then
    print("ERROR: Couldn't read your 1Password keychain in '" .. basedir .. "'.")
    print("ERROR: Please make sure it exists and you have permission to access it.")
    print("ERROR: (maybe you need to run 'ln -s ~/Dropbox/1Password' here?")
    print("ERROR: Giving up for now.")
    os.exit(1)
end
f:close()

-- !!! FIXME: this can probably happen in C now (the Lua mainline is basically gone now).
setPowermateLED(false)  -- off by default
print("Now waiting for the magic key combo (probably Alt-Meta-\\) ...")
giveControlToGui()

-- end of 1pass.lua ...