renaming keys

This commit is contained in:
Anedroid 2022-12-28 19:16:17 +01:00
parent 912046663c
commit 5ebc9ed080
Signed by: anedroid
GPG Key ID: F149EE15E69C7F45
10 changed files with 274 additions and 37 deletions

View File

@ -1,5 +1,5 @@
# example stargazer config, nothing special here
listen = 127.0.0.1
# listening on 0.0.0.0 binds to all interfaces, while 127.0.0.1 only to localhost
listen = 0.0.0.0
[:tls]
store = certs
@ -10,3 +10,11 @@ root = public
[localhost:/cgi]
root = public
cgi = on
# repeated configuration for your domain
[example.com]
root = public
[example.com:/cgi]
root = public
cgi = on

View File

@ -4,6 +4,8 @@ After linked, key cannot be unlinked. It can only be deleted.
You need to have at least one key linked.
When you link new key to the existing account, a random token is generated for authentication for short period of time. That token is burned after use.
You can request your account to be deleted. To proceed, you have to solve simple anti-csrf challenge.
When the key is linked with account, a display name is being automatically set from certificate's issuer field.
You can change display name of your key. Because of nature of Gemini query strings, it's a two-step process: first, the key hash is temporary stored in database, actual renaming happens in the second step.
Access points:
/cgi - cgi scripts root
@ -35,6 +37,18 @@ Access points:
match
empty - redirect to index.gmi
anticsrf+hash - verify & redirect to index.gmi | warning
/rename-request.gmi
no CC - request CC
mismatch - invalid CC
match
empty - which key would you like to rename?
anticsrf+hash - verify & redirect to rename.gmi | warning
/rename.gmi
no CC - request CC
mismatch - invalid CC
match
empty - choose your name for "[old name]" | redirect to rename-request.gmi
string - rename & redirect to index.gmi
/delete.gmi - delete your account
no CC - request CC
mismatch - invalid CC
@ -53,7 +67,8 @@ CREATE TABLE IF NOT EXISTS users (
request_delete VARCHAR(16),
request_delete_time INTEGER,
anticsrf VARCHAR(4),
anticsrf_time INTEGER
anticsrf_time INTEGER,
request_rename VARCHAR(255)
)
CREATE TABLE IF NOT EXISTS keys (
hash VARCHAR(255) PRIMARY KEY,

View File

@ -20,11 +20,18 @@ class auth:
ANTIC_EXPIRE = 60*60*24
hash = None
certName = None
username = None
anticsrf = False
# User row cache. It's enough to ask database once and update cache only when asked for outdated or missing columns.
user = {}
userOutdated = []
anticsrf = False
# User keys cache indexed by hashes
keys = {}
keysOutdated = ["all"]
# getKeys always returns all keys owned by user.
def __init__(self, dbFile):
"""
@ -45,7 +52,8 @@ class auth:
request_delete VARCHAR(16),
request_delete_time INTEGER,
anticsrf VARCHAR(4),
anticsrf_time INTEGER
anticsrf_time INTEGER,
request_rename VARCHAR(255)
)
""")
self.cur.execute("""
@ -59,21 +67,27 @@ class auth:
)
""")
# TODO: database migration
# self.migrateDatabase()
self.garbageCollector()
def garbageCollector(self):
"""
delete all unlinked keys
delete all unlinked keys and expired tokens
"""
# garbageCollector is intended to run before caching initialization
self.cur.execute("DELETE FROM keys WHERE user IS NULL")
self.cur.execute("UPDATE users SET link_token = NULL, link_token_time = NULL WHERE link_token_time + ? - strftime('%s') <= 0", (self.LINK_EXPIRE, ))
# field request_rename expire together with anticsrf
self.cur.execute("UPDATE users SET anticsrf = NULL, anticsrf_time = NULL, request_rename = NULL WHERE anticsrf_time + ? - strftime('%s') <= 0", (self.ANTIC_EXPIRE, ))
self.con.commit()
def passKey(self, hash):
def passKey(self, hash, name=None):
"""
pass given key to the object
"""
self.hash = hash
self.certName = name
key = self.fetchKey()
if (not key):
@ -92,19 +106,48 @@ class auth:
return None
res = self.cur.execute("SELECT keys.*, users.name as username FROM users, keys WHERE users.id = keys.user AND keys.hash = ?", (self.hash, ))
return res.fetchone()
res = res.fetchone()
def getKeys(self):
if (res):
self.keys[self.hash] = res.copy()
del self.keys[self.hash]['username']
del self.keys[self.hash]['hash']
return res
def getKeys(self, require=[]):
"""
get all keys of current user
"require" argument controls which fields must be up to date - if ommited, there are no requirements!
"""
if (not self.username):
return None
res = self.cur.execute("SELECT * FROM users, keys WHERE users.id = keys.user AND users.name = ?", (self.username, ))
return res.fetchall()
outdated = False
if ('all' not in self.keysOutdated):
for key in require:
if (key in self.keysOutdated):
outdated = True
break
else:
self.keysOutdated.remove('all')
outdated = True
def userInfo(self, key):
if (outdated):
res = self.cur.execute("SELECT keys.* FROM users, keys WHERE users.id = keys.user AND users.name = ?", (self.username, ))
res = res.fetchall()
self.keys = {}
for key in res:
self.keys[key['hash']] = key
del key['hash']
self.keysOutdated.clear()
if (DEBUG):
logging.warning({"keys": self.keys, "keysOutdated": self.keysOutdated})
return self.keys
def userInfo(self, column):
"""
get user row from database/cache
"""
@ -112,24 +155,36 @@ class auth:
return None
if (DEBUG):
logging.warning({"user": self.user, "userOutdated": self.userOutdated, "key": key})
logging.warning({"user": self.user, "userOutdated": self.userOutdated, "requested": column})
if (key in self.user and key not in self.userOutdated):
return self.user[key]
if (column in self.user and column not in self.userOutdated):
return self.user[column]
res = self.cur.execute("SELECT * FROM users WHERE users.name = ?", (self.username, ))
self.user = res.fetchone()
self.userOutdated.clear()
return self.user[key]
return self.user[column]
def updateUserInfo(self, key, value):
def updateUserInfo(self, column, value):
"""
handy function for quick update self.user and self.userOutdated
for outdating it's enough to self.userOutdated.append(key)
"""
self.user[key] = value
while (key in self.userOutdated): # remove duplicates
self.userOutdated.remove(key)
self.user[column] = value
while (column in self.userOutdated): # remove duplicates
self.userOutdated.remove(column)
def updateKeyInfo(self, hash, column, value):
"""
handy function (at the moment almost inevitable) for quick update self.keys and self.keysOutdated
for outdating it's enough to self.keysOutdated.append(key)
"""
if (hash not in self.keys):
self.keys[hash] = {}
self.keys[hash][column] = value
while (column in self.keysOutdated): # remove duplicates
self.keysOutdated.remove(column)
def genAnticSRF(self):
"""
@ -156,7 +211,6 @@ class auth:
"""
check antic cross-site request forgery token validity
Remider: there's one token per session
TODO: anticsrf expiration
"""
if (not self.username):
return None
@ -173,13 +227,13 @@ class auth:
def registerKey(self):
"""
insert new key into database (will be deleted if not linked)
TODO: autoset common name
"""
if (not self.hash):
return None
self.cur.execute("INSERT INTO keys (hash) VALUES (?)", (self.hash, ))
self.cur.execute("INSERT INTO keys (hash, name) VALUES (?, ?)", (self.hash, self.certName))
# unlinked key is deleted anyway, so why don't wait with commit until linking and avoid unneccessary disk IO?
self.updateKeyInfo(self.hash, 'name', self.certName)
def updateKey(self):
"""
@ -190,6 +244,7 @@ class auth:
self.cur.execute("UPDATE keys SET last_seen = strftime('%s') WHERE hash = ?", (self.hash, ))
self.con.commit()
self.keysOutdated.append('last_seen')
SUCCESS = 0
NAME_IN_USE = 1
@ -218,6 +273,7 @@ class auth:
# now the key is protected from autodeletion
self.con.commit()
self.username = username
self.updateKeyInfo(self.hash, 'user', uid)
return self.SUCCESS
@ -273,6 +329,7 @@ class auth:
self.cur.execute("UPDATE keys SET user = ? WHERE hash = ?", (res['id'], self.hash))
self.burnLink(res['name'])
self.con.commit()
self.updateKeyInfo(self.hash, 'user', res['id'])
self.username = res['name']
return self.SUCCESS
else:
@ -288,12 +345,45 @@ class auth:
if (hash == self.hash):
return self.KEY_IN_USE
myKeys = self.getKeys()
for key in myKeys:
if (key['hash'] == hash):
self.cur.execute("DELETE FROM keys WHERE hash = ?", (hash, ))
self.con.commit()
return self.SUCCESS
if (hash in self.getKeys()):
self.cur.execute("DELETE FROM keys WHERE hash = ?", (hash, ))
self.con.commit()
del self.keys[hash]
return self.SUCCESS
return self.NOT_FOUND
def requestRename(self, hash):
"""
prepare for changing name of given key
"""
if (not self.username):
return None
if (hash in self.getKeys()):
self.cur.execute("UPDATE users SET request_rename = ? WHERE name = ?", (hash, self.username))
self.con.commit()
self.user['request_rename'] = hash
return self.SUCCESS
return self.NOT_FOUND
def renameKey(self, name):
"""
Change the display name of given key
"""
if (not self.username):
return None
hash = self.userInfo('request_rename')
# key hash in database is probably good, but's better safe than sorry
if (hash in self.getKeys()):
self.cur.execute("UPDATE keys SET name = ? WHERE hash = ?", (name, hash))
self.cur.execute("UPDATE users SET request_rename = NULL WHERE name = ?", (self.username, ))
self.con.commit()
self.updateKeyInfo(hash, 'name', name)
self.updateUserInfo('request_rename', None)
return self.SUCCESS
return self.NOT_FOUND
@ -302,6 +392,11 @@ if (__name__ == '__main__'):
import sys
if (len(sys.argv) > 1):
auth(sys.argv[1])
print({"enableRegistration": auth.ENABLE_REGISTRATION})
print({
"enableRegistration": auth.ENABLE_REGISTRATION,
"linkExpire": auth.LINK_EXPIRE,
"anticExpire": auth.ANTIC_EXPIRE,
"debug": DEBUG
})
else:
print('Database file not specified')

View File

@ -10,12 +10,13 @@ if (not hash):
# no CC
print('60 Authentication is required\r\n')
exit()
certName = os.environ.get('REMOTE_USER')
print('20 text/gemini\r\n')
from auth import auth
auth = auth('data/data.db')
auth.passKey(hash)
auth.passKey(hash, certName)
if (not auth.username):
# mismatch
@ -28,8 +29,9 @@ else:
print('Hello, {}!'.format(auth.username))
print('## Your keys')
for key in auth.getKeys():
hash = key['hash']
myKeys = auth.getKeys(['last_seen'])
for hash in myKeys:
key = myKeys[hash]
lastSeen = datetime.fromtimestamp(key['last_seen'])
current = hash == auth.hash
@ -43,6 +45,7 @@ else:
print('hash:', hash)
print('last seen:', lastSeen)
print('=> rename-request.gmi?{} rename'.format(auth.genAnticSRF() + hash))
if (not current):
print('=> unlink.gmi?{} unlink'.format(auth.genAnticSRF() + hash))
@ -59,4 +62,5 @@ else:
print('Token {} will expire in {}:{}'.format(linkToken, zero(minutes), zero(seconds)))
print('=> link.gmi?cancel cancel')
else:
print('---')
print('=> link.gmi link new key')

View File

@ -10,10 +10,11 @@ if (not hash):
# no CC
print('60 Authentication is required\r\n')
exit()
certName = os.environ.get('REMOTE_USER')
from auth import auth
auth = auth('data/data.db')
auth.passKey(hash)
auth.passKey(hash, certName)
query = os.environ.get('QUERY_STRING')

View File

@ -10,10 +10,11 @@ if (not hash):
# no CC
print('60 Authentication is required\r\n')
exit()
certName = os.environ.get('REMOTE_USER')
from auth import auth
auth = auth('data/data.db')
auth.passKey(hash)
auth.passKey(hash, certName)
if (auth.username):
# match

View File

@ -0,0 +1,68 @@
#!/usr/bin/python3
import os
import sys
os.chdir('..')
sys.path.append('lib')
hash = os.environ.get('TLS_CLIENT_HASH')
if (not hash):
# no CC
print('60 Authentication is required\r\n')
exit()
certName = os.environ.get('REMOTE_USER')
from auth import auth
auth = auth('data/data.db')
auth.passKey(hash, certName)
if (not auth.username):
# mismatch
print('61 Unknown key\r\n')
else:
# match
query = os.environ.get('QUERY_STRING')
if (not query):
# empty
from datetime import datetime
print('20 text/gemini\r\n')
print('Which key would you like to rename?')
myKeys = auth.getKeys(['last_seen'])
for hash in myKeys:
key = myKeys['hash']
lastSeen = datetime.fromtimestamp(key['last_seen'])
current = hash == auth.hash
name = key['name']
name = '"' + name + '"' if name else '[no name]'
label = '=> rename-request.gmi?{} {}'.format(auth.genAnticSRF() + hash, name)
if (current):
label += ' (currently used)'
print(label)
print('hash:', hash)
print('last seen:', lastSeen)
else:
anticsrf = query[:4]
hash = query[4:]
if (not anticsrf or not hash):
print('59 What are you trying to do?\r\n')
exit()
# anticsrf+hash
if (auth.checkAnticSRF(anticsrf)):
res = auth.requestRename(hash)
if (res == auth.SUCCESS):
print('30 rename.gmi\r\n')
elif (res == auth.NOT_FOUND):
print('20 text/gemini\r\n')
print('Failed to rename non-existing key, or key which does not belong to you.')
print('=> index.gmi back to home')
else:
print('40 Unknown error\r\n')
else:
print('50 Bad Antic SRF (security)\r\n')

42
public/cgi/account/rename.gmi Executable file
View File

@ -0,0 +1,42 @@
#!/usr/bin/python3
import os
import sys
os.chdir('..')
sys.path.append('lib')
hash = os.environ.get('TLS_CLIENT_HASH')
if (not hash):
# no CC
print('60 Authentication is required\r\n')
exit()
certName = os.environ.get('REMOTE_USER')
from auth import auth
auth = auth('data/data.db')
auth.passKey(hash, certName)
if (not auth.username):
# mismatch
print('61 Unknown key\r\n')
else:
# match
name = os.environ.get('QUERY_STRING')
if (not name):
# empty
if (auth.userInfo('request_rename')):
# TODO: tell which key are you renaming
print('10 Choose new name for your key\r\n')
else:
print('30 rename-request.gmi\r\n')
else:
# string
res = auth.renameKey(name)
if (res == auth.SUCCESS):
print('30 index.gmi\r\n')
elif (res == auth.NOT_FOUND):
print('20 text/gemini\r\n')
print('Failed to rename non-existing key, or key which does not belong to you.')
print('=> index.gmi back to home')
else:
print('40 Unknown error\r\n')

View File

@ -10,10 +10,11 @@ if (not hash):
# no CC
print('60 Authentication is required\r\n')
exit()
certName = os.environ.get('REMOTE_USER')
from auth import auth
auth = auth('data/data.db')
auth.passKey(hash)
auth.passKey(hash, certName)
if (not auth.username):
# mismatch

View File

@ -10,14 +10,16 @@ hash = os.environ.get('TLS_CLIENT_HASH')
if (not hash):
print('60 Authentication is required\r\n')
exit()
certName = os.environ.get('REMOTE_USER')
print('20 text/gemini\r\n')
from auth import auth
auth = auth('data/data.db')
auth.passKey(hash)
auth.passKey(hash, certName)
print('Your hash:', auth.hash)
print('Your common name:', certName)
if (auth.username):
print('Your username:', auth.username)
print('=> account/index.gmi manage your account')