veilid/scripts/earthly/secretsd/secretsd/database.py
2022-01-09 15:17:36 -05:00

378 lines
15 KiB
Python

import base64
import sqlite3
import time
from .encryption import (generate_key,
aes_cfb8_wrap, aes_cfb8_unwrap,
aes_cfb128_wrap, aes_cfb128_unwrap)
from .external_keys import load_ext_key, store_ext_key
class SecretsDatabase():
def __init__(self, path, key_path):
self.db = sqlite3.connect(path)
self.kp = key_path
self.mk = None
self.dk = None
self.ver = 0
self.initialize()
self.upgrade()
self.load_keys()
def initialize(self):
cur = self.db.cursor()
cur.execute("CREATE TABLE IF NOT EXISTS version (" \
" version INTEGER" \
")")
cur.execute("CREATE TABLE IF NOT EXISTS sequence (" \
" next INTEGER" \
")")
cur.execute("CREATE TABLE IF NOT EXISTS parameters (" \
" name TEXT," \
" value TEXT" \
")")
cur.execute("CREATE TABLE IF NOT EXISTS collections (" \
" object TEXT," \
" label TEXT," \
" created INTEGER," \
" modified INTEGER" \
")")
cur.execute("CREATE TABLE IF NOT EXISTS aliases (" \
" alias TEXT," \
" target TEXT" \
")")
cur.execute("CREATE TABLE IF NOT EXISTS items (" \
" object TEXT," \
" label TEXT," \
" created INTEGER," \
" modified INTEGER" \
")")
cur.execute("CREATE TABLE IF NOT EXISTS attributes (" \
" object TEXT," \
" attribute TEXT," \
" value TEXT" \
")")
cur.execute("CREATE TABLE IF NOT EXISTS secrets (" \
" object TEXT," \
" secret TEXT," \
" type TEXT" \
")")
self.db.commit()
# Encryption keys
def _store_mkey(self, key):
print("DB: storing master key to %r" % (self.kp))
store_ext_key(self.kp, base64.b64encode(key).decode())
def _load_mkey(self):
print("DB: loading master key from %r" % (self.kp))
try:
mkey = base64.b64decode(load_ext_key(self.kp))
if len(mkey) != 32:
raise IOError("wrong mkey length (expected 32 bytes)")
except (KeyError, FileNotFoundError):
raise RuntimeError("could not load the database key from %r" % (self.kp))
self.mk = mkey
def _load_dkey(self, *, v=0):
if (v or self.ver) == 3:
cur = self.db.cursor()
cur.execute("SELECT value FROM parameters WHERE name = 'dkey'")
dkey, = cur.fetchone()
try:
dkey = self._decrypt_buf(dkey, with_mkey=True, v=3)
except IOError as e:
raise IOError("wrong mkey (%s)" % e)
if len(dkey) != 32:
raise IOError("wrong dkey length (expected 32 bytes)")
self.dk = dkey
else:
raise NotImplementedError("unknown schema version %r" % (v or self.ver))
def load_keys(self):
if self.ver >= 2:
self._load_mkey()
self._load_dkey()
def _encrypt_buf(self, buf, *, with_mkey=False, v=0):
key = self.mk if with_mkey else self.dk
if (v or self.ver) >= 3:
return aes_cfb128_wrap(buf, key)
elif (v or self.ver) == 2:
return aes_cfb8_wrap(buf, key)
else:
raise NotImplementedError("unknown schema version %r" % (v or self.ver))
def _decrypt_buf(self, buf, *, with_mkey=None, v=0):
key = self.mk if with_mkey else self.dk
if (v or self.ver) >= 3:
return aes_cfb128_unwrap(buf, key)
elif (v or self.ver) == 2:
return aes_cfb8_unwrap(buf, key)
else:
raise NotImplementedError("unknown schema version %r" % (v or self.ver))
# Schema upgrades
def _upgrade_v0_to_v1(self):
# Undo commit affc514 "make items use bus paths underneath their collection"
cur = self.db.cursor()
cur.execute("SELECT object FROM items" \
" WHERE object LIKE '/org/freedesktop/secrets/collection/c%/i%'")
res = cur.fetchall()
for (old_object,) in res:
item_id = old_object.split("/")[-1]
new_object = "/org/freedesktop/secrets/item/%s" % item_id
print("DB: moving object %r => %r" % (old_object, new_object))
cur.execute("UPDATE items SET object = ? WHERE object = ?",
(new_object, old_object))
cur.execute("UPDATE secrets SET object = ? WHERE object = ?",
(new_object, old_object))
cur.execute("UPDATE attributes SET object = ? WHERE object = ?",
(new_object, old_object))
def _upgrade_v1_to_v3(self):
# Version 2 encrypts all secrets using the database master key
cur = self.db.cursor()
# Generate a "master key"
print("DB: generating a master key")
mkey = generate_key()
self._store_mkey(mkey)
self.mk = mkey
# Generate a "data key"
print("DB: generating a data key")
dkey = generate_key()
blob = self._encrypt_buf(dkey, with_mkey=True, v=3)
cur.execute("INSERT INTO parameters VALUES ('dkey', ?)", (blob,))
self.dk = dkey
# Encrypt all currently stored secrets
cur.execute("SELECT object, secret FROM secrets")
res = cur.fetchall()
for object, blob in res:
print("DB: encrypting secret %r" % (object,))
blob = self._encrypt_buf(blob, v=3)
cur.execute("UPDATE secrets SET secret = ? WHERE object = ?", (blob, object))
def _upgrade_v2_to_v3(self):
# Version 3 uses AES-CFB128 instead of (badly chosen) AES-CFB8
cur = self.db.cursor()
# Re-encrypt the data key
self._load_mkey()
cur.execute("SELECT value FROM parameters WHERE name = 'dkey'")
blob, = cur.fetchone()
blob = self._decrypt_buf(blob, with_mkey=True, v=2)
blob = self._encrypt_buf(blob, with_mkey=True, v=3)
cur.execute("UPDATE parameters SET value = ? WHERE name = 'dkey'", (blob,))
# Re-encrypt all currently stored secrets
self._load_dkey(v=3)
cur.execute("SELECT object, secret FROM secrets")
res = cur.fetchall()
for object, blob in res:
print("DB: re-encrypting secret %r" % (object,))
blob = self._decrypt_buf(blob, v=2)
blob = self._encrypt_buf(blob, v=3)
cur.execute("UPDATE secrets SET secret = ? WHERE object = ?", (blob, object))
def upgrade(self):
print("DB: current database version is %d" % self.get_version())
if self.get_version() == 0:
print("DB: upgrading to version %d" % (1,))
self._upgrade_v0_to_v1()
self.db.cursor().execute("UPDATE version SET version = ?", (1,))
self.db.commit()
if self.get_version() == 1:
print("DB: upgrading to version %d" % (3,))
self._upgrade_v1_to_v3()
self.db.cursor().execute("UPDATE version SET version = ?", (3,))
self.db.commit()
print("DB: vacuuming database")
self.db.cursor().execute("VACUUM")
if self.get_version() == 2:
print("DB: upgrading to version %d" % (3,))
self._upgrade_v2_to_v3()
self.db.cursor().execute("UPDATE version SET version = ?", (3,))
self.db.commit()
self.ver = self.get_version()
print("DB: new database version is %d" % self.ver)
def get_version(self):
cur = self.db.cursor()
cur.execute("SELECT version FROM version")
res = cur.fetchone()
if res:
version = res[0]
else:
version = 0
cur.execute("INSERT INTO version VALUES (?)", (version,))
self.db.commit()
return version
def get_next_object_id(self):
cur = self.db.cursor()
cur.execute("SELECT next FROM sequence")
res = cur.fetchone()
if res:
oid = res[0]
cur.execute("UPDATE sequence SET next = next + 1")
else:
oid = 0
cur.execute("INSERT INTO sequence VALUES (?)", (oid + 1,))
self.db.commit()
print("DB: allocated new object ID %r" % oid)
return oid
# Collections
def add_collection(self, object, label):
print("DB: adding collection %r with label %r" % (object, label))
now = int(time.time())
cur = self.db.cursor()
cur.execute("INSERT INTO collections VALUES (?,?,?,?)", (object, label, now, now))
self.db.commit()
def list_collections(self):
cur = self.db.cursor()
cur.execute("SELECT object FROM collections")
return [r[0] for r in cur.fetchall()]
def collection_exists(self, object):
return bool(self.get_collection_metadata(object))
def get_collection_metadata(self, object):
print("DB: getting collection metadata for %r" % (object,))
cur = self.db.cursor()
cur.execute("SELECT label, created, modified FROM collections WHERE object = ?",
(object,))
return cur.fetchone()
def set_collection_label(self, object, label):
print("DB: setting label for %r to %r" % (object, label))
now = int(time.time())
cur = self.db.cursor()
cur.execute("UPDATE collections SET label = ?, modified = ? WHERE object = ?",
(label, now, object))
self.db.commit()
def delete_collection(self, object):
print("DB: deleting collection %r" % (object,))
cur = self.db.cursor()
subquery = "SELECT object FROM attributes" \
" WHERE attribute = 'xdg:collection' AND value = ?"
cur.execute("DELETE FROM items WHERE object IN (" + subquery + ")", (object,))
cur.execute("DELETE FROM secrets WHERE object IN (" + subquery + ")", (object,))
cur.execute("DELETE FROM attributes WHERE object IN (" + subquery + ")", (object,))
cur.execute("DELETE FROM aliases WHERE target = ?", (object,))
cur.execute("DELETE FROM collections WHERE object = ?", (object,))
self.db.commit()
# Aliases
def add_alias(self, alias, target):
print("DB: adding alias %r -> %r" % (alias, target))
cur = self.db.cursor()
cur.execute("DELETE FROM aliases WHERE alias = ?", (alias,))
cur.execute("INSERT INTO aliases VALUES (?,?)", (alias, target))
self.db.commit()
def get_aliases(self):
cur = self.db.cursor()
cur.execute("SELECT alias, target FROM aliases")
return cur.fetchall()
def resolve_alias(self, alias):
print("DB: resolving alias %r" % (alias,))
cur = self.db.cursor()
cur.execute("SELECT target FROM aliases WHERE alias = ?", (alias,))
r = cur.fetchone()
return r[0] if r else None
def delete_alias(self, alias):
print("DB: deleting alias %r" % (alias,))
cur = self.db.cursor()
cur.execute("DELETE FROM aliases WHERE alias = ?", (alias,))
self.db.commit()
# Items
def add_item(self, object, label, attrs, secret, sec_type):
now = int(time.time())
cur = self.db.cursor()
cur.execute("INSERT INTO items VALUES (?,?,?,?)", (object, label, now, now))
for key, val in attrs.items():
cur.execute("INSERT INTO attributes VALUES (?,?,?)", (object, key, val))
cur.execute("INSERT INTO secrets VALUES (?,?,?)", (object, self._encrypt_buf(secret),
sec_type))
self.db.commit()
def find_items(self, match_attrs):
qry = "SELECT object FROM attributes WHERE attribute = ? AND value = ?"
qry = " INTERSECT ".join([qry] * len(match_attrs))
parvs = []
for k, v in match_attrs.items():
parvs += [k, v]
print("DB: searching for %r" % parvs)
cur = self.db.cursor()
cur.execute(qry, parvs)
return [r[0] for r in cur.fetchall()]
def item_exists(self, object):
return bool(self.get_item_metadata(object))
def get_item_metadata(self, object):
print("DB: getting metadata for %r" % object)
cur = self.db.cursor()
cur.execute("SELECT label, created, modified FROM items WHERE object = ?",
(object,))
return cur.fetchone()
def set_item_label(self, object, label):
print("DB: setting label for %r to %r" % (object, label))
now = int(time.time())
cur = self.db.cursor()
cur.execute("UPDATE items SET label = ?, modified = ? WHERE object = ?",
(label, now, object))
self.db.commit()
def get_item_attributes(self, object):
print("DB: getting attrs for %r" % object)
cur = self.db.cursor()
cur.execute("SELECT attribute, value FROM attributes WHERE object = ?", (object,))
return {k: v for k, v in cur.fetchall()}
def set_item_attributes(self, object, attrs):
print("DB: setting attrs for %r to %r" % (object, attrs))
now = int(time.time())
cur = self.db.cursor()
cur.execute("DELETE FROM attributes WHERE object = ?", (object,))
for key, val in attrs.items():
cur.execute("INSERT INTO attributes VALUES (?,?,?)", (object, key, val))
cur.execute("UPDATE items SET modified = ? WHERE object = ?", (now, object))
self.db.commit()
def get_secret(self, object):
print("DB: getting secret for %r" % object)
cur = self.db.cursor()
cur.execute("SELECT secret, type FROM secrets WHERE object = ?", (object,))
secret, sec_type = cur.fetchone()
return self._decrypt_buf(secret), sec_type
def set_secret(self, object, secret, sec_type):
print("DB: updating secret for %r" % object)
if hasattr(secret, "encode"):
raise ValueError("secret needs to be bytes, not str")
now = int(time.time())
cur = self.db.cursor()
cur.execute("UPDATE secrets SET secret = ?, type = ? WHERE object = ?",
(self._encrypt_buf(secret), sec_type, object))
cur.execute("UPDATE items SET modified = ? WHERE object = ?",
(now, object))
self.db.commit()
def delete_item(self, object):
print("DB: deleting item %r" % object)
cur = self.db.cursor()
cur.execute("DELETE FROM attributes WHERE object = ?", (object,))
cur.execute("DELETE FROM secrets WHERE object = ?", (object,))
cur.execute("DELETE FROM items WHERE object = ?", (object,))
self.db.commit()