mirror of
https://github.com/alexgo-io/stacks-puppet-node.git
synced 2026-03-28 23:58:29 +08:00
511 lines
12 KiB
Python
511 lines
12 KiB
Python
#!/usr/bin/env python
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Blockstack-client
|
|
~~~~~
|
|
copyright: (c) 2014-2015 by Halfmoon Labs, Inc.
|
|
copyright: (c) 2016 by Blockstack.org
|
|
|
|
This file is part of Blockstack-client.
|
|
|
|
Blockstack-client is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
Blockstack-client is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
You should have received a copy of the GNU General Public License
|
|
along with Blockstack-client. If not, see <http://www.gnu.org/licenses/>.
|
|
"""
|
|
|
|
import socket
|
|
import base64
|
|
import json
|
|
import storage
|
|
import config
|
|
import posixpath
|
|
import jsontokens
|
|
import jsonschema
|
|
from jsonschema.exceptions import ValidationError
|
|
import re
|
|
import keylib
|
|
|
|
from .schemas import *
|
|
from .constants import BLOCKSTACK_TEST, CONFIG_PATH, BLOCKSTACK_DEBUG
|
|
from .keys import HDWallet, get_pubkey_hex
|
|
import scripts
|
|
|
|
log = config.get_logger()
|
|
|
|
def is_user_zonefile(d):
|
|
"""
|
|
Is the given dict (or dict-like object) a user zonefile?
|
|
* the zonefile must have a URI record
|
|
"""
|
|
|
|
try:
|
|
jsonschema.validate(d, USER_ZONEFILE_SCHEMA)
|
|
return True
|
|
except ValidationError as ve:
|
|
if BLOCKSTACK_TEST:
|
|
log.exception(ve)
|
|
|
|
return False
|
|
|
|
|
|
def has_mutable_data_section(d):
|
|
"""
|
|
Does the given dictionary have a mutable data section?
|
|
"""
|
|
try:
|
|
assert isinstance(d, dict)
|
|
if 'data' not in d.keys():
|
|
return False
|
|
|
|
jsonschema.validate(d, PROFILE_MUTABLE_DATA_SCHEMA)
|
|
return True
|
|
except ValidationError as ve:
|
|
if BLOCKSTACK_TEST:
|
|
log.exception(ve)
|
|
|
|
return False
|
|
|
|
except AssertionError:
|
|
return False
|
|
|
|
|
|
def user_zonefile_data_pubkey(user_zonefile, key_prefix='pubkey:data:'):
|
|
"""
|
|
Get a user's data public key from their zonefile.
|
|
There can be only one.
|
|
|
|
Return the uncompressed data public key on success
|
|
Return None if not defined
|
|
Raise if there are multiple ones.
|
|
"""
|
|
if not is_user_zonefile(user_zonefile):
|
|
log.debug("invalid zone file")
|
|
return None
|
|
|
|
if 'txt' not in user_zonefile:
|
|
return None
|
|
|
|
data_pubkey = None
|
|
# check that there is only one of these
|
|
for txtrec in user_zonefile['txt']:
|
|
if not txtrec['txt'].startswith(key_prefix):
|
|
continue
|
|
|
|
if data_pubkey is not None:
|
|
log.error('Invalid zone file: multiple data keys')
|
|
raise ValueError('{} starting with "{}"'.format(msg, key_prefix))
|
|
|
|
data_pubkey = txtrec['txt'][len(key_prefix):]
|
|
|
|
# must be DER-encoded EC public key--either uncompressed or compressed
|
|
if not re.match(r'^[0-9a-fA-F]{66}$', data_pubkey) and not re.match(r'[0-9a-fA-F]{130}$', data_pubkey):
|
|
data_pubkey = None
|
|
|
|
if data_pubkey is None:
|
|
return None
|
|
|
|
# uncompressed!
|
|
if keylib.key_formatting.get_pubkey_format(data_pubkey) == 'hex_compressed':
|
|
data_pubkey = keylib.key_formatting.decompress(data_pubkey)
|
|
|
|
return data_pubkey
|
|
|
|
|
|
def user_zonefile_set_data_pubkey(user_zonefile, pubkey_hex, key_prefix='pubkey:data:'):
|
|
"""
|
|
Set the data public key in the zonefile.
|
|
NOTE: you will need to re-sign all your data!
|
|
"""
|
|
assert is_user_zonefile(user_zonefile)
|
|
|
|
user_zonefile.setdefault('txt', [])
|
|
|
|
txt = '{}{}'.format(key_prefix, str(pubkey_hex))
|
|
|
|
for txtrec in user_zonefile['txt']:
|
|
if txtrec['txt'].startswith(key_prefix):
|
|
# overwrite
|
|
txtrec['txt'] = txt
|
|
return user_zonefile
|
|
|
|
# not present. add.
|
|
name_txt = {'name': 'pubkey', 'txt': txt}
|
|
user_zonefile['txt'].append(name_txt)
|
|
|
|
return user_zonefile
|
|
|
|
|
|
def user_zonefile_remove_data_pubkey(user_zonefile, key_prefix='pubkey:data:'):
|
|
"""
|
|
Remove the data public key in the zonefile.
|
|
NOTE: you will need to re-sign all your data!
|
|
"""
|
|
assert is_user_zonefile(user_zonefile)
|
|
|
|
user_zonefile.setdefault('txt', [])
|
|
|
|
new_txts = []
|
|
for txtrec in user_zonefile['txt']:
|
|
if not txtrec['txt'].startswith(key_prefix):
|
|
new_txts.append(txtrec)
|
|
|
|
user_zonefile['txt'] = new_txts
|
|
return user_zonefile
|
|
|
|
|
|
def user_zonefile_urls(user_zonefile):
|
|
"""
|
|
Given a user's zonefile, get the profile URLs
|
|
"""
|
|
assert is_user_zonefile(user_zonefile)
|
|
|
|
if 'uri' not in user_zonefile:
|
|
return None
|
|
|
|
ret = []
|
|
for urirec in user_zonefile['uri']:
|
|
if 'target' in urirec:
|
|
ret.append(urirec['target'].strip('"'))
|
|
|
|
return ret
|
|
|
|
|
|
def add_user_zonefile_url(user_zonefile, url):
|
|
"""
|
|
Add a url to a zonefile
|
|
Return the new zonefile on success
|
|
Return None on error or on duplicate URL
|
|
"""
|
|
assert is_user_zonefile(user_zonefile)
|
|
|
|
user_zonefile.setdefault('uri', [])
|
|
|
|
# avoid duplicates
|
|
for urirec in user_zonefile['uri']:
|
|
target = urirec.get('target', '')
|
|
if target.strip('"') == url:
|
|
return None
|
|
|
|
new_urirec = url_to_uri_record(url)
|
|
user_zonefile['uri'].append(new_urirec)
|
|
|
|
return user_zonefile
|
|
|
|
|
|
def remove_user_zonefile_url(user_zonefile, url):
|
|
"""
|
|
Remove a url from a zonefile
|
|
Return the new zonefile on success
|
|
Return None on error.
|
|
"""
|
|
|
|
assert is_user_zonefile(user_zonefile)
|
|
|
|
if 'uri' not in user_zonefile:
|
|
return None
|
|
|
|
for urirec in user_zonefile['uri']:
|
|
target = urirec.get('target', '')
|
|
if target.strip('"') == url:
|
|
user_zonefile['uri'].remove(urirec)
|
|
|
|
return user_zonefile
|
|
|
|
|
|
def make_empty_user_profile():
|
|
"""
|
|
Given a user's name, create an empty profile.
|
|
"""
|
|
ret = {
|
|
'@type': 'Person',
|
|
'accounts': [],
|
|
}
|
|
|
|
return ret
|
|
|
|
|
|
def put_immutable_data_zonefile(user_zonefile, data_id, data_hash, data_url=None):
|
|
"""
|
|
Add a data hash to a user's zonefile. Make sure it's a valid hash as well.
|
|
Return True on success
|
|
Return False otherwise.
|
|
"""
|
|
|
|
if not is_user_zonefile(user_zonefile):
|
|
log.debug("Invalid zone file structure")
|
|
return False
|
|
|
|
data_hash = str(data_hash)
|
|
assert scripts.is_valid_hash(data_hash)
|
|
|
|
k = get_immutable_data_hashes(user_zonefile, data_id)
|
|
if k is not None:
|
|
# exists or name collision
|
|
return k[0] == data_hash
|
|
|
|
txtrec = '#{}'.format(data_hash)
|
|
if data_url is not None:
|
|
txtrec = '{}{}'.format(data_url, txtrec)
|
|
|
|
user_zonefile.setdefault('txt', [])
|
|
|
|
name_txt = {'name': data_id, 'txt': txtrec}
|
|
user_zonefile['txt'].append(name_txt)
|
|
|
|
return True
|
|
|
|
|
|
def get_immutable_hash_from_txt(txtrec):
|
|
"""
|
|
Given an immutable data txt record,
|
|
get the hash.
|
|
The hash is the suffix that begins with #.
|
|
Return None if invalid or not present
|
|
"""
|
|
if '#' not in txtrec:
|
|
return None
|
|
|
|
h = txtrec.split('#')[-1]
|
|
if not scripts.is_valid_hash(h):
|
|
return None
|
|
|
|
return h
|
|
|
|
|
|
def get_immutable_url_from_txt(txtrec):
|
|
"""
|
|
Given an immutable data txt record,
|
|
get the URL hint.
|
|
This is everything that starts before the last #.
|
|
Return None if there is no URL, or we can't parse the txt record
|
|
"""
|
|
if '#' not in txtrec:
|
|
return None
|
|
|
|
url = '#'.join(txtrec.split('#')[:-1])
|
|
|
|
return url or None
|
|
|
|
|
|
def remove_immutable_data_zonefile(user_zonefile, data_hash):
|
|
"""
|
|
Remove a data hash from a user's zonefile.
|
|
Return True if removed
|
|
Return False if not present
|
|
"""
|
|
assert is_user_zonefile(user_zonefile)
|
|
|
|
data_hash = str(data_hash)
|
|
assert scripts.is_valid_hash(data_hash), 'Invalid data hash "{}"'.format(data_hash)
|
|
|
|
if 'txt' not in user_zonefile:
|
|
return False
|
|
|
|
for txtrec in user_zonefile['txt']:
|
|
h = None
|
|
try:
|
|
h = get_immutable_hash_from_txt(txtrec['txt'])
|
|
if h is None:
|
|
continue
|
|
|
|
assert scripts.is_valid_hash(h)
|
|
|
|
except AssertionError as ae:
|
|
log.debug("Invalid immutable data hash")
|
|
continue
|
|
|
|
if data_hash == h:
|
|
user_zonefile['txt'].remove(txtrec)
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def has_immutable_data(user_zonefile, data_hash):
|
|
"""
|
|
Does the given user have the given immutable data?
|
|
Return True if so
|
|
Return False if not
|
|
"""
|
|
assert is_user_zonefile(user_zonefile)
|
|
|
|
data_hash = str(data_hash)
|
|
assert scripts.is_valid_hash(data_hash), 'Invalid data hash "{}"'.format(data_hash)
|
|
|
|
if 'txt' not in user_zonefile:
|
|
return False
|
|
|
|
for txtrec in user_zonefile['txt']:
|
|
h = None
|
|
try:
|
|
h = get_immutable_hash_from_txt(txtrec['txt'])
|
|
if h is None:
|
|
continue
|
|
|
|
assert scripts.is_valid_hash(h)
|
|
|
|
except AssertionError as ae:
|
|
log.error("Invalid immutable data hash")
|
|
continue
|
|
|
|
if data_hash == h:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def has_immutable_data_id(user_zonefile, data_id):
|
|
"""
|
|
Does the given user have the given immutable data?
|
|
Return True if so
|
|
Return False if not
|
|
"""
|
|
if not is_user_zonefile(user_zonefile):
|
|
log.debug("Not a valid zone file")
|
|
return False
|
|
|
|
if 'txt' not in user_zonefile:
|
|
return False
|
|
|
|
for txtrec in user_zonefile['txt']:
|
|
d_id = None
|
|
try:
|
|
d_id = txtrec['name']
|
|
h = get_immutable_hash_from_txt(txtrec['txt'])
|
|
if h is None:
|
|
continue
|
|
|
|
assert scripts.is_valid_hash(h)
|
|
except AssertionError:
|
|
continue
|
|
|
|
if data_id == d_id:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def get_immutable_data_hashes(user_zonefile, data_id):
|
|
"""
|
|
Get the hash of an immutable datum by name.
|
|
Return None if there is no match.
|
|
Return the list of hashes otherwise
|
|
"""
|
|
assert is_user_zonefile(user_zonefile)
|
|
|
|
if 'txt' not in user_zonefile:
|
|
return None
|
|
|
|
ret = None
|
|
for txtrec in user_zonefile['txt']:
|
|
h, d_id = None, None
|
|
|
|
try:
|
|
d_id = txtrec['name']
|
|
if data_id != d_id:
|
|
continue
|
|
|
|
h = get_immutable_hash_from_txt(txtrec['txt'])
|
|
if h is None:
|
|
continue
|
|
|
|
msg = 'Invalid data hash for "{}" (got "{}" from {})'
|
|
assert scripts.is_valid_hash(h), msg.format(d_id, h, txtrec['txt'])
|
|
except AssertionError as ae:
|
|
if BLOCKSTACK_TEST is not None:
|
|
log.exception(ae)
|
|
|
|
continue
|
|
|
|
if ret is None:
|
|
ret = [h]
|
|
else:
|
|
ret.append(h)
|
|
|
|
return ret
|
|
|
|
|
|
def get_immutable_data_url(user_zonefile, data_hash):
|
|
"""
|
|
Given the hash of an immutable datum, find the associated
|
|
URL hint (if given)
|
|
Return None if not given, or not found.
|
|
"""
|
|
|
|
assert is_user_zonefile(user_zonefile)
|
|
|
|
if 'txt' not in user_zonefile:
|
|
return None
|
|
|
|
for txtrec in user_zonefile['txt']:
|
|
h = None
|
|
try:
|
|
h = get_immutable_hash_from_txt(txtrec['txt'])
|
|
if h is None:
|
|
continue
|
|
|
|
assert scripts.is_valid_hash(h)
|
|
|
|
if data_hash != h:
|
|
continue
|
|
|
|
url = get_immutable_url_from_txt(txtrec['txt'])
|
|
except AssertionError as ae:
|
|
log.debug("Invalid immutable data hash {}".format(h))
|
|
continue
|
|
|
|
return url
|
|
|
|
return None
|
|
|
|
|
|
def list_immutable_data(user_zonefile):
|
|
"""
|
|
Get the IDs and hashes of all immutable data
|
|
Return [(data ID, hash)]
|
|
"""
|
|
assert is_user_zonefile(user_zonefile)
|
|
|
|
ret = []
|
|
if 'txt' not in user_zonefile:
|
|
return ret
|
|
|
|
for txtrec in user_zonefile['txt']:
|
|
try:
|
|
d_id = txtrec['name']
|
|
h = get_immutable_hash_from_txt(txtrec['txt'])
|
|
assert scripts.is_valid_hash(h)
|
|
ret.append((d_id, h))
|
|
except AssertionError as ae:
|
|
log.error("Invalid immutable data hash")
|
|
continue
|
|
|
|
return ret
|
|
|
|
|
|
def urls_from_uris( uri_records ):
|
|
"""
|
|
Get the list of URLs from a list of URI records
|
|
"""
|
|
return [u['target'].strip('"') for u in uri_records]
|
|
|
|
|
|
def mutable_data_urls(mutable_info):
|
|
"""
|
|
Get the URLs from a mutable data zonefile
|
|
"""
|
|
uri_records = mutable_info.get('uri')
|
|
|
|
if uri_records is None:
|
|
return None
|
|
|
|
return urls_from_uris( uri_records )
|