class Config(BaseModel): """ Represents and defines config for PassTheSalt. """ owner: fields.Optional(fields.Str) master: fields.Optional(Master)
class Algorithm(BaseModel): """ A secret generation algorithm. """ version: fields.Optional(fields.Int, default=1) length: fields.Optional(fields.Int)
class Author(Model): """Contains information about an author. """ name: fields.Str() surname: fields.Str() affiliation: fields.Optional(fields.List(fields.Str())) def __init__(self, soup): """Creates an `Author` by parsing a `soup` of type `BeautifulSoup`. """ if not soup.persname: self.name = "" self.surname = "" else: self.name = text(soup.persname.forename) self.surname = text(soup.persname.surname) # TODO: better affiliation parsing. self.affiliation = list(map(text, soup.find_all("affiliation"))) def __str__(self): s = "" if self.name: s += self.name + " " if self.surname: s += self.surname return s.strip()
class Generatable(Secret): """ A generatable Secret. """ salt: fields.Str() algorithm: fields.Optional(Algorithm, default=Algorithm) def display(self): """ A display tuple for this tabulating this secret. Returns: (str, str, str): the label, the kind, and the salt. """ return super().display() + (self.salt,) def get(self): """ Generate the secret value for this Secret. Returns: str: the secret value. """ return generate( self.salt, self._pts.master_key, version=self.algorithm.version, length=self.algorithm.length, )
class Login(Generatable): """ An account login Secret. """ domain: fields.Domain() username: fields.Str() iteration: fields.Optional(fields.Int) @property def salt(self): """ The salt for this Generatable secret. Returns: str: the salt. """ return '|'.join((self.domain, self.username, str(self.iteration or 0)))
class Config(Model): # misc mode: fields.Str() log_level: fields.Str() logger_name: fields.Optional(fields.Str) sleep_interval: fields.Float(normalizers=[float]) keys_base_path: fields.Str() path_to_keys: fields.Optional(fields.Str) # Only used in tests # db stuff db_name: fields.Str() db_uri: fields.Optional(fields.Str) db_host: fields.Optional(fields.Str) db_password: fields.Optional(fields.Str) db_username: fields.Optional(fields.Str) # multisig stuff signatures_threshold: fields.Int(normalizers=[int]) signatures_threshold_eth: fields.Optional(fields.Int(normalizers=[int])) multisig_wallet_address: fields.Str() # Ethereum address multisig_acc_addr: fields.Str() # SN address multisig_key_name: fields.Str() secret_signers: fields.Str() # ethereum stuff eth_node: fields.Str() network: fields.Str() eth_start_block: fields.Int(normalizers=[int]) eth_confirmations: fields.Int(normalizers=[int]) # eth account stuff eth_address: fields.Optional(fields.Str) eth_private_key: fields.Optional(fields.Str) pkcs11_module: fields.Optional(fields.Str) token: fields.Optional(fields.Str) user_pin: fields.Optional(fields.Str) label: fields.Optional(fields.Str) # oracle stuff ethgastation_api_key: fields.Optional(fields.Str) # secret network stuff secretcli_home: fields.Str() secret_node: fields.Str() enclave_key: fields.Str() chain_id: fields.Str() scrt_swap_address: fields.Str() swap_code_hash: fields.Str() # scrt account stuff secret_key_file: fields.Str() secret_key_name: fields.Str() secret_key_password: fields.Optional(fields.Str) # warnings eth_funds_warning_threshold: fields.Float(normalizers=[float]) scrt_funds_warning_threshold: fields.Float(normalizers=[float])
class Version(Model): major = fields.Int() minor = fields.Int() patch = fields.Optional(fields.Int, default=0)
class User(Model): name = fields.Str(rename='username', serializers=[lambda s: s.strip()]) age = fields.Optional(fields.Int) addresses = fields.Optional(fields.List(Address))
class Article(_Article): """Represents an academic article or a reference contained in an article. The data is parsed from a TEI XML file (`from_file()`) or directly from a `BeautifulSoup` object. """ title: fields.Str() text: fields.Str() authors: fields.List(Author) year: fields.Optional(fields.Date()) references: fields.Optional(fields.List(_Article)) def __init__(self, soup, is_reference=False): """Create a new `Article` by parsing a `soup: BeautifulSoup` instance. The parameter `is_reference` specifies if the `soup` contains an entire article or just the content of a reference. """ self.title = text(soup.title) self.doi = text(soup.idno) self.abstract = text(soup.abstract) self.text = soup.text.strip() if soup.text else "" # FIXME self.year = None if is_reference: self.authors = list(map(Author, soup.find_all("author"))) self.references = [] else: self.authors = list(map(Author, soup.analytic.find_all("author"))) self.references = self._parse_biblio(soup) @staticmethod def from_file(tei_file): """Creates an `Article` by parsing a TEI XML file. """ with open(tei_file) as f: soup = BeautifulSoup(f, "lxml") return Article(soup) def _parse_biblio(self, soup): """Parses the bibliography from an article. """ references = [] # NOTE: we could do this without the regex. bibs = soup.find_all("biblstruct", {"xml:id": re.compile(r"b[0-9]*")}) for bib in bibs: if bib.analytic: references.append(Article(bib.analytic, is_reference=True)) # NOTE: in this case, bib.monogr contains more info # about the manuscript where the paper was published. # Not parsing for now. elif bib.monogr: references.append(Article(bib.monogr, is_reference=True)) else: print(f"Could not parse reference from {bib}") return references def __str__(self): return f"'{self.title}' - {' '.join(map(str, self.authors))}" def summary(self): """Prints a human-readable summary. """ print(f"Title: {self.title}") print("Authors: " + ", ".join(map(str, self.authors))) if self.references: print("References:") for r in self.references: r.summary() print("-------------------")
class Example(Model): a = fields.Optional(fields.Int)
class Example(Model): a = fields.Optional(fields.Int, default=5)
class Example(Model): a = fields.Int() b = fields.Optional(fields.Bool)
class PassTheSalt(Model): """ An object to store and manage Secrets. A PassTheSalt represents and defines a deterministic password generation and password storage system. """ config: fields.Optional(Config, default=Config) secrets: fields.Optional(fields.Dict(fields.Str, Secret), default=dict) secrets_encrypted: fields.Optional(fields.Str) version: fields.Optional( fields.Literal(MAJOR_VERSION), default=MAJOR_VERSION, normalizers=[major_version], ) def __init__(self, *args, **kwargs): """ Create a new PassTheSalt. """ super().__init__(*args, **kwargs) self._master = None @classmethod def from_dict(cls, d): """ Create a PassTheSalt object from a dictionary. Args: d (dict): the input dictionary. Returns: PassTheSalt: a new PassTheSalt object. """ pts = super().from_dict(d) # Add the current context to each Secret. for label, secret in pts.secrets.items(): secret.add_context(label, pts) return pts def save(self, dict=None, **kwargs): """ Write this PassTheSalt store to the configured path. Args: dict (type): the class of the deserialized dictionary. This defaults to an `OrderedDict` so that the fields will be returned in the order they were defined on the Model. **kwargs: extra keyword arguments passed directly to `json.dumps()`. """ self.to_path(self.path, dict=dict, **kwargs) def with_master(self, master): """ Configure PassTheSalt with a master password. Args: master: the master password for generating and encrypting secrets. This can be a callback for getting the password (for example through user input), or the actual master password as a string. Returns: PassTheSalt: this object. """ self._master = master return self def with_path(self, path): """ Configure PassTheSalt with a default path. Args: path (str): the default path to read and write to. Returns: PassTheSalt: this object. """ self._path = path return self @property def master_key(self): """ Return the master key. This is constructed from the master password and the configured owner. Returns: str: the master key. """ if self._master is None: raise ConfigurationError('no master password is configured') if callable(self._master): self._master = self._master(self) key = [] if self.config.owner: key.append(self.config.owner) key.append(self._master) return '|'.join(key) @property def path(self): """ Return the configured path if it is set. Returns: str: the configured path. Raises: `ConfigurationError`: when there is no configured path. """ try: return self._path except AttributeError: raise ConfigurationError('no default path is configured') def labels(self, pattern=None): """ Return the list of labels for secrets. This list can be optionally filtered with a regex pattern. Args: pattern (str): filter labels with a regex pattern. Returns: list: a list of labels matching the given pattern and prefix. Raises: LabelError: when the given pattern is an invalid regex expression. """ labels = self.secrets.keys() if pattern: try: regex = re.compile(pattern) except re.error: raise LabelError(f'{pattern!r} is an invalid regex expression') labels = filter(regex.match, labels) return list(labels) def resolve(self, pattern): """ Resolve a pattern and prefix to a single label. Args: pattern (str): filter labels with a regex pattern. Returns: str: the actual label of the secret. Raises: LabelError: if the pattern does not match any labels or multiple labels are matched. """ if self.contains(pattern): return pattern matches = self.labels(pattern=pattern) if len(matches) == 1: return matches[0] elif not matches: raise LabelError(f'unable to resolve pattern {pattern!r}') else: raise LabelError(f'pattern {pattern!r} matches multiple secrets') def contains(self, label): """ Whether the label exists. Args: label (str): the label for the secret. Returns: bool: True if the label exists else False. """ return label in self.secrets def add(self, label, secret): """ Add a secret to PassTheSalt. Args: label (str): the label for the secret. secret (Secret): the secret to add. """ if self.contains(label): raise LabelError(f'{label!r} already exists') secret.add_context(label, self) secret.add() self.secrets[label] = secret self.touch() def get(self, label): """ Retrieve a secret. Args: label (str): the label for the secret. Returns: Secret: the secret corresponding to the label. """ return self.secrets[label] def pop(self, label): """ Remove a secret and return the removed secret. Args: label (str): the label for the secret. Returns: Secret: the secret corresponding to the label. """ try: secret = self.secrets.pop(label) except KeyError: raise LabelError(f'{label!r} does not exist') secret.remove() secret.remove_context() self.touch() return secret def remove(self, label): """ Remove a secret. Args: label (str): the label for the secret. """ self.pop(label) def move(self, label, new_label): """ Rename a secret. Args: label (str): the label for the secret. new_label (str): the new label for the secret. """ if self.contains(new_label): raise LabelError(f'{new_label!r} already exists') self.add(new_label, self.pop(label)) def update(self, label, secret): """ Update secret. Args: label (str): the label for the secret. secret (Secret): the secret to update with. """ if self.contains(label): self.remove(label) self.add(label, secret) def _diff(self, other): """ Return the difference between this store and the other. The returned PassTheSalt store contains everything in the current store that is not present in the other, and anything that is not equal. Warning: this method is private API for a reason. The returned PassTheSalt store is not usable as a store. Args: other (PassTheSalt): the store to compare with. Returns: PassTheSalt: a store with all the extra / missing secrets. """ diff = PassTheSalt() for label in self.labels(): if label not in other.labels() or self.get(label) != other.get(label): diff.secrets[label] = self.get(label) return diff
class Model(BaseModel): """ A custom Model that has a modified Field. """ modified: fields.Optional(DateTime, default=datetime.datetime.utcnow) def to_base64(self, **kwargs): """ Dump the model as a JSON string and base64 encode it. Args: **kwargs: extra keyword arguments to pass directly to `json.dumps`. Returns: str: a base64 encoded representation of this model. """ return b64encode(self.to_json(**kwargs).encode()).decode('ascii') def to_toml(self, **kwargs): """ Dump the model as a TOML string. Args: **kwargs: extra keyword arguments to pass directly to `toml.dumps`. Returns: str: a TOML representation of this model. """ return toml.dumps(self.to_dict(), **kwargs) def to_path(self, p, dict=None, **kwargs): """ Dump the model to a file path. Args: p (str): the file path to write to. **kwargs: extra keyword arguments to pass directly to `json.dumps`. """ with open(p, 'w') as f: f.write(self.to_json(**kwargs)) @classmethod def from_base64(cls, s, **kwargs): """ Load the model from a base64 encoded string. Args: s (str): the JSON string. **kwargs: extra keyword arguments to pass directly to `json.loads`. Returns: Model: an instance of this model. . """ return cls.from_json(b64decode(s.encode()).decode('utf-8'), **kwargs) @classmethod def from_toml(cls, s, **kwargs): """ Load the model from a TOML string. Args: s (str): the TOML string. **kwargs: extra keyword arguments to pass directly to `toml.loads`. Returns: Model: an instance of this model. """ return cls.from_dict(toml.loads(s, **kwargs)) @classmethod def from_path(cls, p, **kwargs): """ Load the model from a file path. Args: p (str): the file path to read from. **kwargs: extra keyword arguments to pass directly to `json.loads`. Returns: Model: an instance of this model. . """ with open(p) as f: return cls.from_json(f.read(), **kwargs) def touch(self): """ Update the modified time of this object to the current UTC time. """ self.modified = datetime.datetime.utcnow()
class Stow(Remote): """ Stow configuration for a remote store. See https://github.com/rossmacarthur/stow. """ token: fields.Optional(fields.Str) token_location: fields.Url() @property def headers(self): """ General headers to use when making requests to the remote server. Returns: dict: the headers. """ return {'Content-Type': 'application/json'} def validate_response(self, response): """ Validate the given response. Args: response (Response): the requests response. Raises: UnauthorizedAccess: when the response code is 401. ConflictingTimestamps: when the response code is 409. """ try: message = response.json().get('message') except json.decoder.JSONDecodeError: message = None if response.status_code == 401: raise UnauthorizedAccess(message) elif response.status_code == 409: raise ConflictingTimestamps(message) super().validate_response(response) def renew(self): """ Renew the internal token with the configured authentication. """ data = self.request('GET', self.token_location, headers=self.headers, auth=self.auth).json() self.token = data['token'] self.touch() def handle_renew(self, f): """ Decorator to recall a function if the token needs to be renewed. If the given function raises UnauthorizedAccess then renew the token and recall the function. Args: f (callable): the function that accesses some secure resource. Returns: the result of the function. """ def decorated_function(*args, **kwargs): try: return f(*args, **kwargs) except UnauthorizedAccess: self.renew() return f(*args, **kwargs) return decorated_function def get(self): """ Retrieve the Remote store. Returns: PassTheSalt: a PassTheSalt instance. """ @self.handle_renew def get(): return self.request('GET', self.location, headers=self.headers, auth=(self.token, 'unused')).json() return PassTheSalt.from_base64(get()['value']) def put(self, pts, force=False): """ Upload the given PassTheSalt to the Remote store. Args: pts (PassTheSalt): a PassTheSalt instance. force (bool): whether to ignore any conflicts. Returns: dict: the message from the server. """ @self.handle_renew def put(pts): payload = {'value': pts.to_base64()} if not force: payload['modified'] = pts.modified.isoformat() data = json.dumps(payload) return self.request( 'PUT', self.location, headers=self.headers, auth=(self.token, 'unused'), data=data, ) try: message = put(pts).json().get('message') except json.decoder.JSONDecodeError: message = None return message