diff --git a/CONFIG.md b/CONFIG.md index 7f5805e..12eb6a1 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -188,12 +188,28 @@ provide the same Key ID and CEK for both Video and Audio, as well as for multipl You can have as many Key Vaults as you would like. It's nice to share Key Vaults or use a unified Vault on Teams as sharing CEKs immediately can help reduce License calls drastically. -Two types of Vaults are in the Core codebase, SQLite and MySQL Vaults. Both directly connect to an SQLite or MySQL -Server. It has to connect directly to the Host/IP. It cannot be in front of a PHP API or such. Beware that some Hosts -do not let you access the MySQL server outside their intranet (aka Don't port forward or use permissive network -interfaces). +Three types of Vaults are in the Core codebase, API, SQLite and MySQL. API makes HTTP requests to a RESTful API, +whereas SQLite and MySQL directly connect to an SQLite or MySQL Database. -### Connecting to a MySQL Vault +Note: SQLite and MySQL vaults have to connect directly to the Host/IP. It cannot be in front of a PHP API or such. +Beware that some Hosting Providers do not let you access the MySQL server outside their intranet and may not be +accessible outside their hosting platform. + +### Using an API Vault + +API vaults use a specific HTTP request format, therefore API or HTTP Key Vault APIs from other projects or services may +not work in Devine. The API format can be seen in the [API Vault Code](devine/vaults/API.py). + +```yaml +- type: API + name: "John#0001's Vault" # arbitrary vault name + uri: "https://key-vault.example.com" # api base uri (can also be an IP or IP:Port) + # uri: "127.0.0.1:80/key-vault" + # uri: "https://api.example.com/key-vault" + token: "random secret key" # authorization token +``` + +### Using a MySQL Vault MySQL vaults can be either MySQL or MariaDB servers. I recommend MariaDB. A MySQL Vault can be on a local or remote network, but I recommend SQLite for local Vaults. @@ -219,7 +235,7 @@ make tables yourself. - You may give trusted users CREATE permission so devine can create tables if needed. - Other uses should only be given SELECT and INSERT permissions. -### Connecting to an SQLite Vault +### Using an SQLite Vault SQLite Vaults are usually only used for locally stored vaults. This vault may be stored on a mounted Cloud storage drive, but I recommend using SQLite exclusively as an offline-only vault. Effectively this is your backup vault in diff --git a/devine/vaults/API.py b/devine/vaults/API.py new file mode 100644 index 0000000..c4de1a3 --- /dev/null +++ b/devine/vaults/API.py @@ -0,0 +1,214 @@ +from typing import Iterator, Optional, Union +from uuid import UUID + +from requests import Session + +from devine.core import __version__ +from devine.core.vault import Vault + + +class API(Vault): + """Key Vault using a simple RESTful HTTP API call.""" + + def __init__(self, name: str, uri: str, token: str): + super().__init__(name) + self.uri = uri.rstrip("/") + self.session = Session() + self.session.headers.update({ + "User-Agent": f"Devine v{__version__}" + }) + self.session.headers.update({ + "Authorization": f"Bearer {token}" + }) + + def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]: + if isinstance(kid, UUID): + kid = kid.hex + + data = self.session.get( + url=f"{self.uri}/{service.lower()}/{kid}", + headers={ + "Accept": "application/json" + } + ).json() + + code = int(data.get("code", 0)) + message = data.get("message") + error = { + 0: None, + 1: Exceptions.AuthRejected, + 2: Exceptions.TooManyRequests, + 3: Exceptions.ServiceTagInvalid, + 4: Exceptions.KeyIdInvalid + }.get(code, ValueError) + + if error: + raise error(f"{message} ({code})") + + content_key = data.get("content_key") + if not content_key: + return None + + if not isinstance(content_key, str): + raise ValueError(f"Expected {content_key} to be {str}, was {type(content_key)}") + + return content_key + + def get_keys(self, service: str) -> Iterator[tuple[str, str]]: + page = 1 + + while True: + data = self.session.get( + url=f"{self.uri}/{service.lower()}", + params={ + "page": page, + "total": 10 + }, + headers={ + "Accept": "application/json" + } + ).json() + + code = int(data.get("code", 0)) + message = data.get("message") + error = { + 0: None, + 1: Exceptions.AuthRejected, + 2: Exceptions.TooManyRequests, + 3: Exceptions.PageInvalid, + 4: Exceptions.ServiceTagInvalid, + }.get(code, ValueError) + + if error: + raise error(f"{message} ({code})") + + content_keys = data.get("content_keys") + if content_keys: + if not isinstance(content_keys, dict): + raise ValueError(f"Expected {content_keys} to be {dict}, was {type(content_keys)}") + + for key_id, key in content_keys.items(): + yield key_id, key + + pages = int(data["pages"]) + if pages <= page: + break + + page += 1 + + def add_key(self, service: str, kid: Union[UUID, str], key: str) -> bool: + if isinstance(kid, UUID): + kid = kid.hex + + data = self.session.post( + url=f"{self.uri}/{service.lower()}/{kid}", + json={ + "content_key": key + }, + headers={ + "Accept": "application/json" + } + ).json() + + code = int(data.get("code", 0)) + message = data.get("message") + error = { + 0: None, + 1: Exceptions.AuthRejected, + 2: Exceptions.TooManyRequests, + 3: Exceptions.ServiceTagInvalid, + 4: Exceptions.KeyIdInvalid, + 5: Exceptions.ContentKeyInvalid + }.get(code, ValueError) + + if error: + raise error(f"{message} ({code})") + + # the kid:key was new to the vault (optional) + added = bool(data.get("added")) + # the key for kid was changed/updated (optional) + updated = bool(data.get("updated")) + + return added or updated + + def add_keys(self, service: str, kid_keys: dict[Union[UUID, str], str]) -> int: + data = self.session.post( + url=f"{self.uri}/{service.lower()}", + json={ + "content_keys": { + str(kid).replace("-", ""): key + for kid, key in kid_keys.items() + } + }, + headers={ + "Accept": "application/json" + } + ).json() + + code = int(data.get("code", 0)) + message = data.get("message") + error = { + 0: None, + 1: Exceptions.AuthRejected, + 2: Exceptions.TooManyRequests, + 3: Exceptions.ServiceTagInvalid, + 4: Exceptions.KeyIdInvalid, + 5: Exceptions.ContentKeyInvalid + }.get(code, ValueError) + + if error: + raise error(f"{message} ({code})") + + # each kid:key that was new to the vault (optional) + added = int(data.get("added")) + # each key for a kid that was changed/updated (optional) + updated = int(data.get("updated")) + + return added + updated + + def get_services(self) -> Iterator[str]: + data = self.session.post( + url=self.uri, + headers={ + "Accept": "application/json" + } + ).json() + + code = int(data.get("code", 0)) + message = data.get("message") + error = { + 0: None, + 1: Exceptions.AuthRejected, + 2: Exceptions.TooManyRequests, + }.get(code, ValueError) + + if error: + raise error(f"{message} ({code})") + + service_list = data.get("service_list", []) + + if not isinstance(service_list, list): + raise ValueError(f"Expected {service_list} to be {list}, was {type(service_list)}") + + for service in service_list: + yield service + + +class Exceptions: + class AuthRejected(Exception): + """Authentication Error Occurred, is your token valid? Do you have permission to make this call?""" + + class TooManyRequests(Exception): + """Rate Limited; Sent too many requests in a given amount of time.""" + + class PageInvalid(Exception): + """Requested page does not exist.""" + + class ServiceTagInvalid(Exception): + """The Service Tag is invalid.""" + + class KeyIdInvalid(Exception): + """The Key ID is invalid.""" + + class ContentKeyInvalid(Exception): + """The Content Key is invalid."""