From f63e78cbc4455103c6dab62fa4de1096b09ec79a Mon Sep 17 00:00:00 2001 From: M Mou Date: Sun, 27 Oct 2019 14:22:59 -0700 Subject: [PATCH 01/15] add KVStoreClient, 6_totp example. --- .gitignore | 1 + .pre-commit-config.yaml | 2 +- README.md | 10 + examples/6_totp.py | 206 +++++++++++++++ poetry.lock | 73 +++--- pykeybasebot/bot.py | 5 + pykeybasebot/kvstore_client.py | 94 +++++++ pykeybasebot/types/chat1/__init__.py | 272 +++++++++++++------ pykeybasebot/types/gregor1/__init__.py | 6 +- pykeybasebot/types/keybase1/__init__.py | 334 +++++++++++++++++------- pykeybasebot/types/stellar1/__init__.py | 170 ++++++------ pyproject.toml | 1 + tests/fixtures/payment.json | 2 + 13 files changed, 878 insertions(+), 298 deletions(-) create mode 100644 examples/6_totp.py create mode 100644 pykeybasebot/kvstore_client.py diff --git a/.gitignore b/.gitignore index 5bc6e21..129023e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ dist/ __pycache__/ .mypy_cache/ +*.egg-info diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 35630a7..0cf9947 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,6 @@ repos: name: mypy stages: [commit] language: system - entry: poetry run mypy **/*.py + entry: poetry run mypy pykeybasebot/ types: [python] pass_filenames: false diff --git a/README.md b/README.md index 6c811d6..772963c 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,16 @@ poetry install This will set up a virtualenv for you and install all the dependencies needed into it! +Remember that if you're making changes to pykeybasebot and want to test them +locally, you'll need to first uninstall previously installed pykeybasebot, +then install your local version: + +``` +pip uninstall pykeybasebot +poetry build +pip install ./dist/pykeybasebot-{tags}.whl +``` + ### Static code analysis tools We use a few different static analysis tools to perform linting, type-checking, formatting, etc. The correct versions should be install when you run `poetry install`, but you'll probably want to configure your editor to work with: diff --git a/examples/6_totp.py b/examples/6_totp.py new file mode 100644 index 0000000..87b20f4 --- /dev/null +++ b/examples/6_totp.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 + +################################### +# WHAT IS IN THIS EXAMPLE? +# +# This is a simple TOTP bot that makes use of +# the team encrypted key-value store, which +# we interact with using KVStoreClient. +################################### + +import asyncio +import logging +import os +import sys +from enum import Enum + +import pyotp + +from pykeybasebot import Bot, EventType + +logging.basicConfig(level=logging.DEBUG) + +if "win32" in sys.platform: + # Windows specific event-loop policy + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + +class TotpMsg(Enum): + PROVISION = "provision" + REPROVISION = "reprovision" + REMOVE = "remove" + NOW = "now" + URI = "uri" + LIST = "list" + + +class TotpHandler: + """ + TotpHandler handles commands sent via chat and uses the team key-value store to + provide TOTP client functionality. + + Background: The team encrypted key-value store stores (key, value) + pairs, with unique (team, namespace, key)s. The value is encrypted, and the + team and namespace are not. + + TotpHandler listens to chat messages of the form: + + `totp {provision|reprovision|remove|now|uri} ` and + `totp list` + + For each provisioned key, the handler stores ("secret", <16 char base32 secret>) + in the team's KV store under the namespace of . This bot assumes + your team's KV store is solely used for TOTP credentials, though this can + easily be modified. + + This Keybase bot can be used in place of MFA apps like Google Authenticator and Authy + for login processes that require two-step verification. This bot uses the + pyotp library, so it is also compatible with other MFA apps. + + A Keybase TOTP bot could be useful for logins where you're already using a strong + password over an secure transport, but are required to use TOTP. Or it could be + perfect for adding replay attack resistance to the physical keypad lock protecting + your team's ice cream freezer.... Note that depending on the login process and + how you use this bot with your Keybase team setup, this bot may not be appropriate + for threat models that require a physical "second factor" device. + + For more information on TOTP, see https://pyotp.readthedocs.io/en/latest/, + https://tools.ietf.org/html/rfc6238. + + This example does minimal error handling. + """ + + MSG_PREFIX = "totp" + + async def __provision(self, bot, team, issuer, force: bool = False): + secret = pyotp.random_base32() + if not force: + try: + # throws exception if 1 is not the latest revision + 1 + await bot.kvstore.put(team, issuer, "secret", secret, revision=1) + except Exception as e: + res = await bot.kvstore.get(team, issuer, "secret") + if res.revision > 0 and res.entry_value == "": + # then TOTP for this issuer was previously provisioned but + # was deleted. insert as latest revision + 1 + await bot.kvstore.put(team, issuer, "secret", secret) + else: + # then TOTP for this issuer was previously provisioned and + # might still be in use, so raise the exception + raise e + else: + # reprovisioning; "force" provisioning by inserting as latest revision + 1 + await bot.kvstore.put(team, issuer, "secret", secret) + return pyotp.TOTP(secret).provisioning_uri(team, issuer_name=issuer) + + async def __remove(self, bot, team, issuer): + # throws exception if nothing to delete + await bot.kvstore.delete(team, issuer, "secret") + + async def __list(self, bot, team): + # returns all namespaces in this team; assumes that all namespaces are + # used for storing TOTP credentials + res = await bot.kvstore.list_namespaces(team) + return res.namespaces # list of namespaces + + async def __uri(self, bot, team, issuer): + totp = await self.__totp(bot, team, issuer) + return totp.provisioning_uri(team, issuer_name=issuer) if totp else None + + async def __now(self, bot, team, issuer): + totp = await self.__totp(bot, team, issuer) + return totp.now() if totp else None + + async def __totp(self, bot, team, issuer): + secret = await bot.kvstore.get(team, issuer, "secret") + if secret.revision > 0 and secret.entry_value != "": + # if secret is present + return pyotp.TOTP(secret.entry_value) + else: + return None + + async def __call__(self, bot, event): + if event.type != EventType.CHAT or event.msg.channel.members_type != "team": + return + + channel = event.msg.channel + team = channel.name + + msg = event.msg.content.text.body.split(" ") + if len(msg) == 2 and msg[0] == self.MSG_PREFIX and msg[1] == TotpMsg.LIST.value: + # chat: "totp list" + ns = await self.__list(bot, team) + await bot.chat.send(channel, str(ns)) + return + + if not (len(msg) == 3 and msg[0] == self.MSG_PREFIX): + return + + action, issuer = msg[1], msg[2] + if action == TotpMsg.PROVISION.value: + # chat: "totp provision " + send_msg = "Error provisioning TOTP for {0}. If this issuer was previously provisioned, confirm reprovisioning with command `totp {1} {0}`.".format( + issuer, TotpMsg.REPROVISION.value + ) + try: + uri = await self.__provision(bot, team, issuer, force=False) + send_msg = "TOTP provisioned for {}. provisioning_uri={}".format( + issuer, uri + ) + except Exception: + pass + finally: + await bot.chat.send(channel, send_msg) + return + if action == TotpMsg.REPROVISION.value: + # chat: "totp reprovision " + send_msg = "Error reprovisioning TOTP for {}".format(issuer) + try: + uri = await self.__provision(bot, team, issuer, force=True) + send_msg = "TOTP reprovisioned for {}. provisioning_uri={}".format( + issuer, uri + ) + except Exception: + pass + finally: + await bot.chat.send(channel, send_msg) + return + if action == TotpMsg.NOW.value: + # chat: "totp now " + send_msg = "Error getting current TOTP for {}".format(issuer) + code = await self.__now(bot, team, issuer) + if code: + send_msg = "Current TOTP for {}: {}".format(issuer, code) + await bot.chat.send(channel, send_msg) + return + if action == TotpMsg.URI.value: + # chat: "totp uri " + send_msg = "Error getting provisioning_uri for {}".format(issuer) + uri = await self.__uri(bot, team, issuer) + if uri: + send_msg = uri + await bot.chat.send(channel, send_msg) + return + if action == TotpMsg.REMOVE.value: + # chat: "totp remove " + send_msg = "No keys to remove for {}".format(issuer) + try: + await self.__remove(bot, team, issuer) + send_msg = "Removed TOTP keys for {}".format(issuer) + except Exception: + pass + finally: + await bot.chat.send(channel, send_msg) + return + + +username = "yourbot" +team = "yourtotpbotteam" + +bot = Bot( + username=username, paperkey=os.environ["KEYBASE_PAPERKEY"], handler=TotpHandler() +) + +listen_options = {"filter-channels": [{"name": team, "members_type": "team"}]} + +asyncio.run(bot.start(listen_options)) diff --git a/poetry.lock b/poetry.lock index 5a7f0fc..5bff0f1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -20,7 +20,7 @@ description = "Classes Without Boilerplate" name = "attrs" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "19.1.0" +version = "19.3.0" [[package]] category = "dev" @@ -54,7 +54,7 @@ description = "Python package for providing Mozilla's CA Bundle." name = "certifi" optional = false python-versions = "*" -version = "2019.6.16" +version = "2019.9.11" [[package]] category = "dev" @@ -87,7 +87,7 @@ description = "Easily serialize dataclasses to and from JSON" name = "dataclasses-json" optional = false python-versions = ">=3.6" -version = "0.3.2" +version = "0.3.5" [package.dependencies] marshmallow = ">=3.0.1,<4.0.0" @@ -136,10 +136,11 @@ version = "2.8" [[package]] category = "dev" description = "Read metadata from Python packages" +marker = "python_version < \"3.8\"" name = "importlib-metadata" optional = false python-versions = ">=2.7,!=3.0,!=3.1,!=3.2,!=3.3" -version = "0.19" +version = "0.23" [package.dependencies] zipp = ">=0.5" @@ -158,7 +159,7 @@ description = "A lightweight library for converting complex datatypes to and fro name = "marshmallow" optional = false python-versions = ">=3.5" -version = "3.0.3" +version = "3.2.1" [[package]] category = "main" @@ -206,7 +207,7 @@ description = "Experimental type system extensions for programs checked with the name = "mypy-extensions" optional = false python-versions = "*" -version = "0.4.1" +version = "0.4.3" [[package]] category = "dev" @@ -214,10 +215,9 @@ description = "Core utilities for Python packages" name = "packaging" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "19.1" +version = "19.2" [package.dependencies] -attrs = "*" pyparsing = ">=2.0.2" six = "*" @@ -235,10 +235,12 @@ description = "plugin and hook calling mechanisms for python" name = "pluggy" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.12.0" +version = "0.13.0" [package.dependencies] -importlib-metadata = ">=0.12" +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12" [[package]] category = "dev" @@ -272,6 +274,14 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" version = "2.4.2" +[[package]] +category = "dev" +description = "Python One Time Password Library" +name = "pyotp" +optional = false +python-versions = "*" +version = "2.3.0" + [[package]] category = "dev" description = "Python parsing module" @@ -286,7 +296,7 @@ description = "pytest: simple powerful testing with Python" name = "pytest" optional = false python-versions = ">=3.5" -version = "5.1.1" +version = "5.2.2" [package.dependencies] atomicwrites = ">=1.0" @@ -371,7 +381,7 @@ description = "Fast, Extensible Progress Meter" name = "tqdm" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*" -version = "4.35.0" +version = "4.36.1" [[package]] category = "dev" @@ -379,7 +389,7 @@ description = "Collection of utilities for publishing packages on PyPI" name = "twine" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.13.0" +version = "1.15.0" [package.dependencies] pkginfo = ">=1.4.2" @@ -422,10 +432,11 @@ description = "Runtime inspection utilities for typing module." name = "typing-inspect" optional = false python-versions = "*" -version = "0.4.0" +version = "0.5.0" [package.dependencies] mypy-extensions = ">=0.3.0" +typing-extensions = ">=3.7.4" [[package]] category = "dev" @@ -433,7 +444,7 @@ description = "HTTP library with thread-safe connection pooling, file post, and name = "urllib3" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4" -version = "1.25.3" +version = "1.25.6" [[package]] category = "dev" @@ -454,6 +465,7 @@ version = "0.5.1" [[package]] category = "dev" description = "Backport of pathlib-compatible object wrapper for zip files" +marker = "python_version < \"3.8\"" name = "zipp" optional = false python-versions = ">=2.7" @@ -463,54 +475,55 @@ version = "0.6.0" more-itertools = "*" [metadata] -content-hash = "d7c99caee6a2a73116d45f1f353961edfc153b294e18839d8c81aa3b90128469" +content-hash = "ccbcaf049809bc1e88e4c9e13712941be9b00dd1f14c9b7c1d8e59a2baa07f2f" python-versions = "^3.7" [metadata.hashes] appdirs = ["9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", "d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"] atomicwrites = ["03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", "75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"] -attrs = ["69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", "f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399"] +attrs = ["08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"] black = ["817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739", "e030a9a28f542debc08acceb273f228ac422798e5215ba2a791a6ddeaaca22a5"] bleach = ["213336e49e102af26d9cde77dd2d0397afabc5a6bf2fed985dc35b5d1e285a16", "3fdf7f77adcf649c9911387df51254b813185e32b2c6619f690b593a617e19fa"] -certifi = ["046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939", "945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695"] +certifi = ["e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", "fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"] chardet = ["84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"] click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"] colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"] -dataclasses-json = ["065a94e07599b28830b55ac17e3a26c49ef88e209513140f2ef1a1954dd303c6", "d5883036560707fb785bc705fd965a0c9b94b44d10acca8ddde9dcc40dc12d61"] +dataclasses-json = ["3f348a132c6c84772b99fca50c447ef3b8382d274fd9a539c958dd9b93ba4806", "8851be971187d22a898247ffff9b23de6d5d1db93b8e648997a71a2e4023d13c"] docutils = ["6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0", "9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827", "a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99"] entrypoints = ["589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"] flake8 = ["19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548", "8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696"] idna = ["c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", "ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"] -importlib-metadata = ["23d3d873e008a513952355379d93cbcab874c58f4f034ff657c7a87422fa64e8", "80d2de76188eabfbfcf27e6a37342c2827801e59c4cc14b0371c56fed43820e3"] +importlib-metadata = ["aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", "d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"] isort = ["54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", "6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"] -marshmallow = ["159c3ed37094d66867bbacdf2e7effd7c7ad88c6b11f9b398ff5ea1d118508c3", "51188df086da5c427c3c193faddf7f95857ee4053dbf2d083e5cbfd846b2fb29"] +marshmallow = ["077b4612f5d3b9333b736fdc6b963d2b46d409070f44ff3e6c4109645c673e83", "9a2f3e8ea5f530a9664e882d7d04b58650f46190178b2264c72b7d20399d28f0"] marshmallow-enum = ["38e697e11f45a8e64b4a1e664000897c659b60aa57bfa18d44e226a9920b6e58", "57161ab3dbfde4f57adeb12090f39592e992b9c86d206d02f6bd03ebec60f072"] mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"] more-itertools = ["409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", "92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"] mypy = ["0107bff4f46a289f0e4081d59b77cef1c48ea43da5a0dbf0005d54748b26df2a", "07957f5471b3bb768c61f08690c96d8a09be0912185a27a68700f3ede99184e4", "10af62f87b6921eac50271e667cc234162a194e742d8e02fc4ddc121e129a5b0", "11fd60d2f69f0cefbe53ce551acf5b1cec1a89e7ce2d47b4e95a84eefb2899ae", "15e43d3b1546813669bd1a6ec7e6a11d2888db938e0607f7b5eef6b976671339", "352c24ba054a89bb9a35dd064ee95ab9b12903b56c72a8d3863d882e2632dc76", "437020a39417e85e22ea8edcb709612903a9924209e10b3ec6d8c9f05b79f498", "49925f9da7cee47eebf3420d7c0e00ec662ec6abb2780eb0a16260a7ba25f9c4", "6724fcd5777aa6cebfa7e644c526888c9d639bd22edd26b2a8038c674a7c34bd", "7a17613f7ea374ab64f39f03257f22b5755335b73251d0d253687a69029701ba", "cdc1151ced496ca1496272da7fc356580e95f2682be1d32377c22ddebdf73c91"] -mypy-extensions = ["37e0e956f41369209a3d5f34580150bcacfabaa57b33a15c0b25f4b5725e0812", "b16cabe759f55e3409a7d231ebd2841378fb0c27a5d1994719e340e4f429ac3e"] -packaging = ["a7ac867b97fdc07ee80a8058fe4435ccd274ecc3b0ed61d852d7d53055528cf9", "c491ca87294da7cc01902edbe30a5bc6c4c28172b5138ab4e4aa1b9d7bfaeafe"] +mypy-extensions = ["090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", "2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"] +packaging = ["28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47", "d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108"] pkginfo = ["7424f2c8511c186cd5424bbf31045b77435b37a8d604990b79d4e70d741148bb", "a6d9e40ca61ad3ebd0b72fbadd4fba16e4c0e4df0428c041e01e06eb6ee71f32"] -pluggy = ["0825a152ac059776623854c1543d65a4ad408eb3d33ee114dff91e57ec6ae6fc", "b9817417e95936bf75d85d3f8767f7df6cdde751fc40aed3bb3074cbcb77757c"] +pluggy = ["0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6", "fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34"] py = ["64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", "dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"] pycodestyle = ["95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"] pyflakes = ["17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", "d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"] pygments = ["71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", "881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"] +pyotp = ["c88f37fd47541a580b744b42136f387cdad481b560ef410c0d85c957eb2a2bc0", "fc537e8acd985c5cbf51e11b7d53c42276fee017a73aec7c07380695671ca1a1"] pyparsing = ["6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80", "d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4"] -pytest = ["95b1f6db806e5b1b5b443efeb58984c24945508f93a866c1719e1a507a957d7c", "c3d5020755f70c82eceda3feaf556af9a341334414a8eca521a18f463bcead88"] +pytest = ["27abc3fef618a01bebb1f0d6d303d2816a99aa87a5968ebc32fe971be91eb1e6", "58cee9e09242937e136dbb3dab466116ba20d6b7828c7620f23947f37eb4dae4"] readme-renderer = ["bb16f55b259f27f75f640acf5e00cf897845a8b3e4731b5c1a436e4b8529202f", "c8532b79afc0375a85f10433eca157d6b50f7d6990f337fa498c96cd4bfc203d"] requests = ["11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", "9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"] requests-toolbelt = ["380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f", "968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"] six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] stringcase = ["48a06980661908efe8d9d34eab2b6c13aefa2163b3ced26972902e3bdfd87008"] toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"] -tqdm = ["1be3e4e3198f2d0e47b928e9d9a8ec1b63525db29095cec1467f4c5a4ea8ebf9", "7e39a30e3d34a7a6539378e39d7490326253b7ee354878a92255656dc4284457"] -twine = ["0fb0bfa3df4f62076cab5def36b1a71a2e4acb4d1fa5c97475b048117b1a6446", "d6c29c933ecfc74e9b1d9fa13aa1f87c5d5770e119f5a4ce032092f0ff5b14dc"] -typed-ast = ["18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", "262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", "2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", "354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", "4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", "630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", "66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", "71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", "95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", "bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", "cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", "d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", "d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", "d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", "ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"] +tqdm = ["abc25d0ce2397d070ef07d8c7e706aede7920da163c64997585d42d3537ece3d", "dd3fcca8488bb1d416aa7469d2f277902f26260c45aa86b667b074cd44b3b115"] +twine = ["630fadd6e342e725930be6c696537e3f9ccc54331742b16245dab292a17d0460", "a3d22aab467b4682a22de4a422632e79d07eebd07ff2a7079effb13f8a693787"] +typed-ast = ["1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161", "18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", "262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", "2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", "354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", "48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47", "4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", "630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", "66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", "71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", "7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2", "838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e", "95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", "bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", "cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", "d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", "d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", "d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", "fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66", "ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"] typing = ["91dfe6f3f706ee8cc32d38edbbf304e9b7583fb37108fef38229617f8b3eba23", "c8cabb5ab8945cd2f54917be357d134db9cc1eb039e59d1606dc1e60cb1d9d36", "f38d83c5a7a7086543a0f649564d661859c5146a85775ab90c0d2f93ffaa9714"] typing-extensions = ["2ed632b30bb54fc3941c382decfd0ee4148f5c591651c9272473fea2c6397d95", "b1edbbf0652660e32ae780ac9433f4231e7339c7f9a8057d0f042fcbcea49b87", "d8179012ec2c620d3791ca6fe2bf7979d979acdbef1fca0bc56b37411db682ed"] -typing-inspect = ["a7cb36c4a47d034766a67ea6467b39bd995cd00db8d4db1aa40001bf2d674a9b", "cf41eb276cc8955a45e03c15cd1efa6c181a8775a38ff0bfda99d28af97bcda3", "e319dfa0c9a646614c9b6abab3bdd5f860a98609998d420f33e41a6e01cbbddb"] -urllib3 = ["b246607a25ac80bedac05c6f282e3cdaf3afb65420fd024ac94435cabe6e18d1", "dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232"] +typing-inspect = ["75c97b7854426a129f3184c68588db29091ff58e6908ed520add1d52fc44df6e", "811b44f92e780b90cfe7bac94249a4fae87cfaa9b40312765489255045231d9c", "c6ed1cd34860857c53c146a6704a96da12e1661087828ce350f34addc6e5eee3"] +urllib3 = ["3de946ffbed6e6746608990594d08faac602528ac7015ac28d33cee6a45b7398", "9a107b99a5393caf59c7aa3c1249c16e6879447533d0887f4336dde834c7be86"] wcwidth = ["3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", "f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c"] webencodings = ["a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", "b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"] zipp = ["3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e", "f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"] diff --git a/pykeybasebot/bot.py b/pykeybasebot/bot.py index 1b3b86b..b54b610 100644 --- a/pykeybasebot/bot.py +++ b/pykeybasebot/bot.py @@ -6,6 +6,7 @@ from .chat_client import ChatClient from .cli import KeybaseNotConnectedError, kblisten, kbsubmit +from .kvstore_client import KVStoreClient RETRY_ATTEMPTS = 100 SLEEP_SECS_BETWEEEN_RETRIES = 1 @@ -108,6 +109,10 @@ def keybase_cli(self) -> str: def chat(self): return ChatClient(self) + @property + def kvstore(self): + return KVStoreClient(self) + async def ensure_initialized(self): if not await self._is_initialized(): await self._initialize() diff --git a/pykeybasebot/kvstore_client.py b/pykeybasebot/kvstore_client.py new file mode 100644 index 0000000..a2c33e3 --- /dev/null +++ b/pykeybasebot/kvstore_client.py @@ -0,0 +1,94 @@ +import json +from typing import Any, Dict, Union + +from .types import keybase1 + + +class KVStoreClient: + def __init__(self, bot): + self.bot = bot + + async def put( + self, + team: str, + namespace: str, + entryKey: str, + entryValue: str, + revision: Union[int, None] = None, + ) -> keybase1.KVPutResult: + await self.bot.ensure_initialized() + args: Dict[str, Any] = { + "method": "put", + "params": { + "options": { + "team": team, + "namespace": namespace, + "entryKey": entryKey, + "entryValue": entryValue, + } + }, + } + if revision: + args["params"]["options"]["revision"] = revision + res = await self.execute(args) + return keybase1.KVPutResult.from_dict(res) + + async def delete( + self, + team: str, + namespace: str, + entryKey: str, + revision: Union[int, None] = None, + ) -> keybase1.KVDeleteEntryResult: + await self.bot.ensure_initialized() + args: Dict[str, Any] = { + "method": "del", + "params": { + "options": {"team": team, "namespace": namespace, "entryKey": entryKey} + }, + } + if revision: + args["params"]["options"]["revision"] = revision + res = await self.execute(args) + return keybase1.KVDeleteEntryResult.from_dict(res) + + async def get( + self, team: str, namespace: str, entryKey: str + ) -> keybase1.KVGetResult: + await self.bot.ensure_initialized() + res = await self.execute( + { + "method": "get", + "params": { + "options": { + "team": team, + "namespace": namespace, + "entryKey": entryKey, + } + }, + } + ) + return keybase1.KVGetResult.from_dict(res) + + async def list_namespaces(self, team: str) -> keybase1.KVListNamespaceResult: + await self.bot.ensure_initialized() + res = await self.execute( + {"method": "list", "params": {"options": {"team": team}}} + ) + return keybase1.KVListNamespaceResult.from_dict(res) + + async def list_entrykeys( + self, team: str, namespace: str + ) -> keybase1.KVListEntryResult: + await self.bot.ensure_initialized() + res = await self.execute( + { + "method": "list", + "params": {"options": {"team": team, "namespace": namespace}}, + } + ) + return keybase1.KVListEntryResult.from_dict(res) + + async def execute(self, command): + resp = await self.bot.submit("kvstore api", json.dumps(command).encode("utf-8")) + return resp["result"] diff --git a/pykeybasebot/types/chat1/__init__.py b/pykeybasebot/types/chat1/__init__.py index 80f9c71..fd3ee23 100644 --- a/pykeybasebot/types/chat1/__init__.py +++ b/pykeybasebot/types/chat1/__init__.py @@ -1,6 +1,6 @@ """chat.1 -Auto-generated to Python types by avdl-compiler v1.4.4 (https://github.com/keybase/node-avdl-compiler) +Auto-generated to Python types by avdl-compiler v1.4.6 (https://github.com/keybase/node-avdl-compiler) Input files: - ../client/protocol/avdl/chat1/api.avdl - ../client/protocol/avdl/chat1/chat_ui.avdl @@ -65,12 +65,28 @@ class MsgSender(DataClassJsonMixin): ) +@dataclass +class MsgBotInfo(DataClassJsonMixin): + bot_uid: str = field(metadata=config(field_name="bot_uid")) + bot_username: Optional[str] = field( + default=None, metadata=config(field_name="bot_username") + ) + + @dataclass class ResetConvMemberAPI(DataClassJsonMixin): conversation_id: str = field(metadata=config(field_name="conversationID")) username: str = field(metadata=config(field_name="username")) +@dataclass +class DeviceInfo(DataClassJsonMixin): + device_id: str = field(metadata=config(field_name="id")) + device_description: str = field(metadata=config(field_name="description")) + device_type: str = field(metadata=config(field_name="type")) + device_ctime: int = field(metadata=config(field_name="ctime")) + + @dataclass class UIPagination(DataClassJsonMixin): next: str = field(metadata=config(field_name="next")) @@ -112,6 +128,14 @@ class UIInboxBigTeamChannelRow(DataClassJsonMixin): draft: Optional[str] = field(default=None, metadata=config(field_name="draft")) +@dataclass +class UIInboxReselectInfo(DataClassJsonMixin): + old_conv_id: str = field(metadata=config(field_name="oldConvID")) + new_conv_id: Optional[str] = field( + default=None, metadata=config(field_name="newConvID") + ) + + @dataclass class UnverifiedInboxUIItemMetadata(DataClassJsonMixin): channel_name: str = field(metadata=config(field_name="channelName")) @@ -163,21 +187,21 @@ class UIAssetUrlInfo(DataClassJsonMixin): @dataclass class UIPaymentInfo(DataClassJsonMixin): - amount_description: str = field(metadata=config(field_name="amountDescription")) + status_description: str = field(metadata=config(field_name="statusDescription")) + issuer_description: str = field(metadata=config(field_name="issuerDescription")) worth: str = field(metadata=config(field_name="worth")) worth_at_send_time: str = field(metadata=config(field_name="worthAtSendTime")) delta: stellar1.BalanceDelta = field(metadata=config(field_name="delta")) note: str = field(metadata=config(field_name="note")) payment_id: stellar1.PaymentID = field(metadata=config(field_name="paymentID")) status: stellar1.PaymentStatus = field(metadata=config(field_name="status")) - status_description: str = field(metadata=config(field_name="statusDescription")) + amount_description: str = field(metadata=config(field_name="amountDescription")) status_detail: str = field(metadata=config(field_name="statusDetail")) show_cancel: bool = field(metadata=config(field_name="showCancel")) from_username: str = field(metadata=config(field_name="fromUsername")) to_username: str = field(metadata=config(field_name="toUsername")) source_amount: str = field(metadata=config(field_name="sourceAmount")) source_asset: stellar1.Asset = field(metadata=config(field_name="sourceAsset")) - issuer_description: str = field(metadata=config(field_name="issuerDescription")) account_id: Optional[stellar1.AccountID] = field( default=None, metadata=config(field_name="accountID") ) @@ -202,6 +226,7 @@ class MessageUnboxedState(Enum): ERROR = 2 OUTBOX = 3 PLACEHOLDER = 4 + JOURNEYCARD = 5 class MessageUnboxedStateStrings(Enum): @@ -209,6 +234,7 @@ class MessageUnboxedStateStrings(Enum): ERROR = "error" OUTBOX = "outbox" PLACEHOLDER = "placeholder" + JOURNEYCARD = "journeycard" @dataclass @@ -1065,10 +1091,14 @@ class MessageSystemTypeStrings(Enum): @dataclass class MessageSystemAddedToTeam(DataClassJsonMixin): team: str = field(metadata=config(field_name="team")) - adder: str = field(metadata=config(field_name="adder")) addee: str = field(metadata=config(field_name="addee")) - owners: Optional[Optional[List[str]]] = field( - default=None, metadata=config(field_name="owners") + role: keybase1.TeamRole = field(metadata=config(field_name="role")) + adder: str = field(metadata=config(field_name="adder")) + bulk_adds: Optional[Optional[List[str]]] = field( + default=None, metadata=config(field_name="bulkAdds") + ) + restricted_bots: Optional[Optional[List[str]]] = field( + default=None, metadata=config(field_name="restrictedBots") ) admins: Optional[Optional[List[str]]] = field( default=None, metadata=config(field_name="admins") @@ -1082,8 +1112,8 @@ class MessageSystemAddedToTeam(DataClassJsonMixin): bots: Optional[Optional[List[str]]] = field( default=None, metadata=config(field_name="bots") ) - restricted_bots: Optional[Optional[List[str]]] = field( - default=None, metadata=config(field_name="restrictedBots") + owners: Optional[Optional[List[str]]] = field( + default=None, metadata=config(field_name="owners") ) @@ -1096,6 +1126,7 @@ class MessageSystemInviteAddedToTeam(DataClassJsonMixin): invite_type: keybase1.TeamInviteCategory = field( metadata=config(field_name="inviteType") ) + role: keybase1.TeamRole = field(metadata=config(field_name="role")) @dataclass @@ -1191,6 +1222,7 @@ class OutboxErrorType(Enum): ALREADY_DELETED = 7 UPLOADFAILED = 8 RESTRICTEDBOT = 9 + MINWRITER = 10 class OutboxErrorTypeStrings(Enum): @@ -1204,6 +1236,7 @@ class OutboxErrorTypeStrings(Enum): ALREADY_DELETED = "already_deleted" UPLOADFAILED = "uploadfailed" RESTRICTEDBOT = "restrictedbot" + MINWRITER = "minwriter" class HeaderPlaintextVersion(Enum): @@ -1286,6 +1319,28 @@ class MessageUnboxedErrorTypeStrings(Enum): PAIRWISE_MISSING = "pairwise_missing" +class JourneycardType(Enum): + WELCOME = 0 + POPULAR_CHANNELS = 1 + ADD_PEOPLE = 2 + CREATE_CHANNELS = 3 + MSG_ATTENTION = 4 + USER_AWAY_FOR_LONG = 5 + CHANNEL_INACTIVE = 6 + MSG_NO_ANSWER = 7 + + +class JourneycardTypeStrings(Enum): + WELCOME = "welcome" + POPULAR_CHANNELS = "popular_channels" + ADD_PEOPLE = "add_people" + CREATE_CHANNELS = "create_channels" + MSG_ATTENTION = "msg_attention" + USER_AWAY_FOR_LONG = "user_away_for_long" + CHANNEL_INACTIVE = "channel_inactive" + MSG_NO_ANSWER = "msg_no_answer" + + @dataclass class UnreadFirstNumLimit(DataClassJsonMixin): num_read: int = field(metadata=config(field_name="NumRead")) @@ -1296,6 +1351,7 @@ class UnreadFirstNumLimit(DataClassJsonMixin): @dataclass class ConversationLocalParticipant(DataClassJsonMixin): username: str = field(metadata=config(field_name="username")) + in_conv_name: bool = field(metadata=config(field_name="inConvName")) fullname: Optional[str] = field( default=None, metadata=config(field_name="fullname") ) @@ -1380,6 +1436,16 @@ class GetThreadNonblockPgModeStrings(Enum): SERVER = "server" +class InboxLayoutReselectMode(Enum): + DEFAULT = 0 + FORCE = 1 + + +class InboxLayoutReselectModeStrings(Enum): + DEFAULT = "default" + FORCE = "force" + + class PreviewLocationTyp(Enum): URL = 0 FILE = 1 @@ -1543,13 +1609,11 @@ class TyperInfo(DataClassJsonMixin): class StaleUpdateType(Enum): CLEAR = 0 NEWACTIVITY = 1 - CONVUPDATE = 2 class StaleUpdateTypeStrings(Enum): CLEAR = "clear" NEWACTIVITY = "newactivity" - CONVUPDATE = "convupdate" class MessageBoxedVersion(Enum): @@ -1748,12 +1812,12 @@ class MsgFlipContent(DataClassJsonMixin): @dataclass class ConvSummary(DataClassJsonMixin): + member_status: str = field(metadata=config(field_name="member_status")) id: str = field(metadata=config(field_name="id")) - channel: ChatChannel = field(metadata=config(field_name="channel")) unread: bool = field(metadata=config(field_name="unread")) active_at: int = field(metadata=config(field_name="active_at")) active_at_ms: int = field(metadata=config(field_name="active_at_ms")) - member_status: str = field(metadata=config(field_name="member_status")) + channel: ChatChannel = field(metadata=config(field_name="channel")) reset_users: Optional[Optional[List[str]]] = field( default=None, metadata=config(field_name="reset_users") ) @@ -1814,6 +1878,13 @@ class GetResetConvMembersRes(DataClassJsonMixin): ) +@dataclass +class GetDeviceInfoRes(DataClassJsonMixin): + devices: Optional[Optional[List[DeviceInfo]]] = field( + default=None, metadata=config(field_name="devices") + ) + + @dataclass class UIInboxBigTeamRow__LABEL(DataClassJsonMixin): state: Literal[UIInboxBigTeamRowTypStrings.LABEL] @@ -1833,6 +1904,7 @@ class UIInboxBigTeamRow__CHANNEL(DataClassJsonMixin): class UIParticipant(DataClassJsonMixin): type: UIParticipantType = field(metadata=config(field_name="type")) assertion: str = field(metadata=config(field_name="assertion")) + in_conv_name: bool = field(metadata=config(field_name="inConvName")) full_name: Optional[str] = field( default=None, metadata=config(field_name="fullName") ) @@ -1841,6 +1913,13 @@ class UIParticipant(DataClassJsonMixin): ) +@dataclass +class UIMessageJourneycard(DataClassJsonMixin): + ordinal: float = field(metadata=config(field_name="ordinal")) + card_type: JourneycardType = field(metadata=config(field_name="cardType")) + highlight_msg_id: MessageID = field(metadata=config(field_name="highlightMsgID")) + + @dataclass class UIMaybeMentionInfo__UNKNOWN(DataClassJsonMixin): status: Literal[UIMaybeMentionStatusStrings.UNKNOWN] @@ -1978,24 +2057,11 @@ class ChannelNameMention(DataClassJsonMixin): @dataclass class GetInboxQuery(DataClassJsonMixin): + skip_bg_loads: bool = field(metadata=config(field_name="skipBgLoads")) unread_only: bool = field(metadata=config(field_name="unreadOnly")) read_only: bool = field(metadata=config(field_name="readOnly")) compute_active_list: bool = field(metadata=config(field_name="computeActiveList")) summarize_max_msgs: bool = field(metadata=config(field_name="summarizeMaxMsgs")) - skip_bg_loads: bool = field(metadata=config(field_name="skipBgLoads")) - conv_id: Optional[ConversationID] = field( - default=None, metadata=config(field_name="convID") - ) - topic_type: Optional[TopicType] = field( - default=None, metadata=config(field_name="topicType") - ) - tlf_id: Optional[TLFID] = field(default=None, metadata=config(field_name="tlfID")) - tlf_visibility: Optional[keybase1.TLFVisibility] = field( - default=None, metadata=config(field_name="tlfVisibility") - ) - before: Optional[gregor1.Time] = field( - default=None, metadata=config(field_name="before") - ) after: Optional[gregor1.Time] = field( default=None, metadata=config(field_name="after") ) @@ -2008,8 +2074,8 @@ class GetInboxQuery(DataClassJsonMixin): status: Optional[Optional[List[ConversationStatus]]] = field( default=None, metadata=config(field_name="status") ) - member_status: Optional[Optional[List[ConversationMemberStatus]]] = field( - default=None, metadata=config(field_name="memberStatus") + topic_type: Optional[TopicType] = field( + default=None, metadata=config(field_name="topicType") ) existences: Optional[Optional[List[ConversationExistence]]] = field( default=None, metadata=config(field_name="existences") @@ -2020,6 +2086,19 @@ class GetInboxQuery(DataClassJsonMixin): conv_i_ds: Optional[Optional[List[ConversationID]]] = field( default=None, metadata=config(field_name="convIDs") ) + conv_id: Optional[ConversationID] = field( + default=None, metadata=config(field_name="convID") + ) + tlf_id: Optional[TLFID] = field(default=None, metadata=config(field_name="tlfID")) + tlf_visibility: Optional[keybase1.TLFVisibility] = field( + default=None, metadata=config(field_name="tlfVisibility") + ) + before: Optional[gregor1.Time] = field( + default=None, metadata=config(field_name="before") + ) + member_status: Optional[Optional[List[ConversationMemberStatus]]] = field( + default=None, metadata=config(field_name="memberStatus") + ) @dataclass @@ -2142,19 +2221,19 @@ class RetentionPolicy__EPHEMERAL(DataClassJsonMixin): @dataclass class SearchOpts(DataClassJsonMixin): + before_context: int = field(metadata=config(field_name="beforeContext")) is_regex: bool = field(metadata=config(field_name="isRegex")) - sent_by: str = field(metadata=config(field_name="sentBy")) sent_to: str = field(metadata=config(field_name="sentTo")) match_mentions: bool = field(metadata=config(field_name="matchMentions")) sent_before: gregor1.Time = field(metadata=config(field_name="sentBefore")) sent_after: gregor1.Time = field(metadata=config(field_name="sentAfter")) max_hits: int = field(metadata=config(field_name="maxHits")) max_messages: int = field(metadata=config(field_name="maxMessages")) - before_context: int = field(metadata=config(field_name="beforeContext")) + sent_by: str = field(metadata=config(field_name="sentBy")) after_context: int = field(metadata=config(field_name="afterContext")) + max_convs_hit: int = field(metadata=config(field_name="maxConvsHit")) reindex_mode: ReIndexingMode = field(metadata=config(field_name="reindexMode")) max_convs_searched: int = field(metadata=config(field_name="maxConvsSearched")) - max_convs_hit: int = field(metadata=config(field_name="maxConvsHit")) max_name_convs: int = field(metadata=config(field_name="maxNameConvs")) initial_pagination: Optional[Pagination] = field( default=None, metadata=config(field_name="initialPagination") @@ -2221,6 +2300,12 @@ class RemoteUserTypingUpdate(DataClassJsonMixin): typing: bool = field(metadata=config(field_name="typing")) +@dataclass +class TeamMemberRoleUpdate(DataClassJsonMixin): + tlf_id: TLFID = field(metadata=config(field_name="tlfID")) + role: keybase1.TeamRole = field(metadata=config(field_name="role")) + + @dataclass class ConversationUpdate(DataClassJsonMixin): conv_id: ConversationID = field(metadata=config(field_name="convID")) @@ -2337,21 +2422,22 @@ class BodyPlaintextUnsupported(DataClassJsonMixin): @dataclass class MessageUnboxedError(DataClassJsonMixin): + sender_device_type: str = field(metadata=config(field_name="senderDeviceType")) err_type: MessageUnboxedErrorType = field(metadata=config(field_name="errType")) - err_msg: str = field(metadata=config(field_name="errMsg")) internal_err_msg: str = field(metadata=config(field_name="internalErrMsg")) version_kind: VersionKind = field(metadata=config(field_name="versionKind")) version_number: int = field(metadata=config(field_name="versionNumber")) is_critical: bool = field(metadata=config(field_name="isCritical")) sender_username: str = field(metadata=config(field_name="senderUsername")) sender_device_name: str = field(metadata=config(field_name="senderDeviceName")) - sender_device_type: str = field(metadata=config(field_name="senderDeviceType")) + err_msg: str = field(metadata=config(field_name="errMsg")) message_id: MessageID = field(metadata=config(field_name="messageID")) message_type: MessageType = field(metadata=config(field_name="messageType")) ctime: gregor1.Time = field(metadata=config(field_name="ctime")) is_ephemeral: bool = field(metadata=config(field_name="isEphemeral")) is_ephemeral_expired: bool = field(metadata=config(field_name="isEphemeralExpired")) etime: gregor1.Time = field(metadata=config(field_name="etime")) + bot_username: str = field(metadata=config(field_name="botUsername")) @dataclass @@ -2360,6 +2446,14 @@ class MessageUnboxedPlaceholder(DataClassJsonMixin): hidden: bool = field(metadata=config(field_name="hidden")) +@dataclass +class MessageUnboxedJourneycard(DataClassJsonMixin): + prev_id: MessageID = field(metadata=config(field_name="prevID")) + ordinal: int = field(metadata=config(field_name="ordinal")) + card_type: JourneycardType = field(metadata=config(field_name="cardType")) + highlight_msg_id: MessageID = field(metadata=config(field_name="highlightMsgID")) + + @dataclass class ConversationSettingsLocal(DataClassJsonMixin): min_writer_role_info: Optional[ConversationMinWriterRoleInfoLocal] = field( @@ -2606,6 +2700,12 @@ class PinMessageRes(DataClassJsonMixin): ) +@dataclass +class LocalMtimeUpdate(DataClassJsonMixin): + conv_id: ConversationID = field(metadata=config(field_name="convID")) + mtime: gregor1.Time = field(metadata=config(field_name="mtime")) + + @dataclass class SetAppNotificationSettingsInfo(DataClassJsonMixin): conv_id: ConversationID = field(metadata=config(field_name="convID")) @@ -2889,6 +2989,12 @@ class UIInboxLayout(DataClassJsonMixin): big_teams: Optional[Optional[List[UIInboxBigTeamRow]]] = field( default=None, metadata=config(field_name="bigTeams") ) + reselect_info: Optional[UIInboxReselectInfo] = field( + default=None, metadata=config(field_name="reselectInfo") + ) + widget_list: Optional[Optional[List[UIInboxSmallTeamRow]]] = field( + default=None, metadata=config(field_name="widgetList") + ) @dataclass @@ -3054,25 +3160,22 @@ class ReactionMap(DataClassJsonMixin): @dataclass class MessageClientHeader(DataClassJsonMixin): conv: ConversationIDTriple = field(metadata=config(field_name="conv")) - tlf_name: str = field(metadata=config(field_name="tlfName")) tlf_public: bool = field(metadata=config(field_name="tlfPublic")) message_type: MessageType = field(metadata=config(field_name="messageType")) supersedes: MessageID = field(metadata=config(field_name="supersedes")) + tlf_name: str = field(metadata=config(field_name="tlfName")) sender: gregor1.UID = field(metadata=config(field_name="sender")) sender_device: gregor1.DeviceID = field(metadata=config(field_name="senderDevice")) pairwise_macs: Dict[str, str] = field(metadata=config(field_name="pm")) + bot_uid: Optional[gregor1.UID] = field( + default=None, metadata=config(field_name="b") + ) kbfs_crypt_keys_used: Optional[bool] = field( default=None, metadata=config(field_name="kbfsCryptKeysUsed") ) deletes: Optional[Optional[List[MessageID]]] = field( default=None, metadata=config(field_name="deletes") ) - prev: Optional[Optional[List[MessagePreviousPointer]]] = field( - default=None, metadata=config(field_name="prev") - ) - delete_history: Optional[MessageDeleteHistory] = field( - default=None, metadata=config(field_name="deleteHistory") - ) merkle_root: Optional[MerkleRoot] = field( default=None, metadata=config(field_name="merkleRoot") ) @@ -3085,27 +3188,24 @@ class MessageClientHeader(DataClassJsonMixin): ephemeral_metadata: Optional[MsgEphemeralMetadata] = field( default=None, metadata=config(field_name="em") ) - bot_uid: Optional[gregor1.UID] = field( - default=None, metadata=config(field_name="b") + prev: Optional[Optional[List[MessagePreviousPointer]]] = field( + default=None, metadata=config(field_name="prev") + ) + delete_history: Optional[MessageDeleteHistory] = field( + default=None, metadata=config(field_name="deleteHistory") ) @dataclass class MessageClientHeaderVerified(DataClassJsonMixin): conv: ConversationIDTriple = field(metadata=config(field_name="conv")) - tlf_name: str = field(metadata=config(field_name="tlfName")) tlf_public: bool = field(metadata=config(field_name="tlfPublic")) message_type: MessageType = field(metadata=config(field_name="messageType")) sender: gregor1.UID = field(metadata=config(field_name="sender")) sender_device: gregor1.DeviceID = field(metadata=config(field_name="senderDevice")) + tlf_name: str = field(metadata=config(field_name="tlfName")) rtime: gregor1.Time = field(metadata=config(field_name="rt")) has_pairwise_macs: bool = field(metadata=config(field_name="pm")) - prev: Optional[Optional[List[MessagePreviousPointer]]] = field( - default=None, metadata=config(field_name="prev") - ) - kbfs_crypt_keys_used: Optional[bool] = field( - default=None, metadata=config(field_name="kbfsCryptKeysUsed") - ) merkle_root: Optional[MerkleRoot] = field( default=None, metadata=config(field_name="merkleRoot") ) @@ -3118,21 +3218,27 @@ class MessageClientHeaderVerified(DataClassJsonMixin): ephemeral_metadata: Optional[MsgEphemeralMetadata] = field( default=None, metadata=config(field_name="em") ) + prev: Optional[Optional[List[MessagePreviousPointer]]] = field( + default=None, metadata=config(field_name="prev") + ) bot_uid: Optional[gregor1.UID] = field( default=None, metadata=config(field_name="b") ) + kbfs_crypt_keys_used: Optional[bool] = field( + default=None, metadata=config(field_name="kbfsCryptKeysUsed") + ) @dataclass class Asset(DataClassJsonMixin): + enc_hash: Hash = field(metadata=config(field_name="encHash")) filename: str = field(metadata=config(field_name="filename")) - region: str = field(metadata=config(field_name="region")) endpoint: str = field(metadata=config(field_name="endpoint")) bucket: str = field(metadata=config(field_name="bucket")) path: str = field(metadata=config(field_name="path")) size: int = field(metadata=config(field_name="size")) mime_type: str = field(metadata=config(field_name="mimeType")) - enc_hash: Hash = field(metadata=config(field_name="encHash")) + region: str = field(metadata=config(field_name="region")) key: str = field(metadata=config(field_name="key")) verify_key: str = field(metadata=config(field_name="verifyKey")) title: str = field(metadata=config(field_name="title")) @@ -3231,6 +3337,9 @@ class ExpungePayload(DataClassJsonMixin): @dataclass class UpdateConversationMembership(DataClassJsonMixin): inbox_vers: InboxVers = field(metadata=config(field_name="inboxVers")) + team_member_role_update: Optional[TeamMemberRoleUpdate] = field( + default=None, metadata=config(field_name="teamMemberRoleUpdate") + ) joined: Optional[Optional[List[ConversationMember]]] = field( default=None, metadata=config(field_name="joined") ) @@ -3334,18 +3443,18 @@ class OutboxState__ERROR(DataClassJsonMixin): @dataclass class HeaderPlaintextV1(DataClassJsonMixin): conv: ConversationIDTriple = field(metadata=config(field_name="conv")) - tlf_name: str = field(metadata=config(field_name="tlfName")) tlf_public: bool = field(metadata=config(field_name="tlfPublic")) message_type: MessageType = field(metadata=config(field_name="messageType")) sender: gregor1.UID = field(metadata=config(field_name="sender")) sender_device: gregor1.DeviceID = field(metadata=config(field_name="senderDevice")) + tlf_name: str = field(metadata=config(field_name="tlfName")) body_hash: Hash = field(metadata=config(field_name="bodyHash")) + bot_uid: Optional[gregor1.UID] = field( + default=None, metadata=config(field_name="b") + ) prev: Optional[Optional[List[MessagePreviousPointer]]] = field( default=None, metadata=config(field_name="prev") ) - kbfs_crypt_keys_used: Optional[bool] = field( - default=None, metadata=config(field_name="kbfsCryptKeysUsed") - ) outbox_info: Optional[OutboxInfo] = field( default=None, metadata=config(field_name="outboxInfo") ) @@ -3361,8 +3470,8 @@ class HeaderPlaintextV1(DataClassJsonMixin): ephemeral_metadata: Optional[MsgEphemeralMetadata] = field( default=None, metadata=config(field_name="em") ) - bot_uid: Optional[gregor1.UID] = field( - default=None, metadata=config(field_name="b") + kbfs_crypt_keys_used: Optional[bool] = field( + default=None, metadata=config(field_name="kbfsCryptKeysUsed") ) @@ -3394,16 +3503,9 @@ class GetThreadQuery(DataClassJsonMixin): @dataclass class GetInboxLocalQuery(DataClassJsonMixin): + compute_active_list: bool = field(metadata=config(field_name="computeActiveList")) unread_only: bool = field(metadata=config(field_name="unreadOnly")) read_only: bool = field(metadata=config(field_name="readOnly")) - compute_active_list: bool = field(metadata=config(field_name="computeActiveList")) - name: Optional[NameQuery] = field(default=None, metadata=config(field_name="name")) - topic_name: Optional[str] = field( - default=None, metadata=config(field_name="topicName") - ) - conv_i_ds: Optional[Optional[List[ConversationID]]] = field( - default=None, metadata=config(field_name="convIDs") - ) topic_type: Optional[TopicType] = field( default=None, metadata=config(field_name="topicType") ) @@ -3413,8 +3515,8 @@ class GetInboxLocalQuery(DataClassJsonMixin): before: Optional[gregor1.Time] = field( default=None, metadata=config(field_name="before") ) - after: Optional[gregor1.Time] = field( - default=None, metadata=config(field_name="after") + topic_name: Optional[str] = field( + default=None, metadata=config(field_name="topicName") ) one_chat_type_per_tlf: Optional[bool] = field( default=None, metadata=config(field_name="oneChatTypePerTLF") @@ -3425,6 +3527,13 @@ class GetInboxLocalQuery(DataClassJsonMixin): member_status: Optional[Optional[List[ConversationMemberStatus]]] = field( default=None, metadata=config(field_name="memberStatus") ) + name: Optional[NameQuery] = field(default=None, metadata=config(field_name="name")) + conv_i_ds: Optional[Optional[List[ConversationID]]] = field( + default=None, metadata=config(field_name="convIDs") + ) + after: Optional[gregor1.Time] = field( + default=None, metadata=config(field_name="after") + ) @dataclass @@ -3923,6 +4032,9 @@ class NewMessagePayload(DataClassJsonMixin): message: MessageBoxed = field(metadata=config(field_name="message")) inbox_vers: InboxVers = field(metadata=config(field_name="inboxVers")) topic_type: TopicType = field(metadata=config(field_name="topicType")) + untrusted_team_role: keybase1.TeamRole = field( + metadata=config(field_name="untrustedTeamRole") + ) unread_update: Optional[UnreadUpdate] = field( default=None, metadata=config(field_name="unreadUpdate") ) @@ -4029,8 +4141,8 @@ class MessageUnfurl(DataClassJsonMixin): @dataclass class MsgContent(DataClassJsonMixin): type_name: str = field(metadata=config(field_name="type")) - text: Optional[MessageText] = field( - default=None, metadata=config(field_name="text") + flip: Optional[MsgFlipContent] = field( + default=None, metadata=config(field_name="flip") ) attachment: Optional[MessageAttachment] = field( default=None, metadata=config(field_name="attachment") @@ -4047,8 +4159,8 @@ class MsgContent(DataClassJsonMixin): metadata: Optional[MessageConversationMetadata] = field( default=None, metadata=config(field_name="metadata") ) - headline: Optional[MessageHeadline] = field( - default=None, metadata=config(field_name="headline") + text: Optional[MessageText] = field( + default=None, metadata=config(field_name="text") ) attachment_uploaded: Optional[MessageAttachmentUploaded] = field( default=None, metadata=config(field_name="attachment_uploaded") @@ -4065,8 +4177,8 @@ class MsgContent(DataClassJsonMixin): unfurl: Optional[MessageUnfurl] = field( default=None, metadata=config(field_name="unfurl") ) - flip: Optional[MsgFlipContent] = field( - default=None, metadata=config(field_name="flip") + headline: Optional[MessageHeadline] = field( + default=None, metadata=config(field_name="headline") ) @@ -4196,20 +4308,22 @@ class MessageBody__PIN(DataClassJsonMixin): @dataclass class MsgSummary(DataClassJsonMixin): id: MessageID = field(metadata=config(field_name="id")) - conv_id: str = field(metadata=config(field_name="conversation_id")) channel: ChatChannel = field(metadata=config(field_name="channel")) sender: MsgSender = field(metadata=config(field_name="sender")) sent_at: int = field(metadata=config(field_name="sent_at")) sent_at_ms: int = field(metadata=config(field_name="sent_at_ms")) content: MsgContent = field(metadata=config(field_name="content")) unread: bool = field(metadata=config(field_name="unread")) - prev: Optional[Optional[List[MessagePreviousPointer]]] = field( - default=None, metadata=config(field_name="prev") + conv_id: str = field(metadata=config(field_name="conversation_id")) + bot_info: Optional[MsgBotInfo] = field( + default=None, metadata=config(field_name="bot_info") ) revoked_device: Optional[bool] = field( default=None, metadata=config(field_name="revoked_device") ) - offline: Optional[bool] = field(default=None, metadata=config(field_name="offline")) + prev: Optional[Optional[List[MessagePreviousPointer]]] = field( + default=None, metadata=config(field_name="prev") + ) kbfs_encrypted: Optional[bool] = field( default=None, metadata=config(field_name="kbfs_encrypted") ) @@ -4237,7 +4351,7 @@ class MsgSummary(DataClassJsonMixin): channel_name_mentions: Optional[Optional[List[UIChannelNameMention]]] = field( default=None, metadata=config(field_name="channel_name_mentions") ) - bot_uid: Optional[str] = field(default=None, metadata=config(field_name="bot_uid")) + offline: Optional[bool] = field(default=None, metadata=config(field_name="offline")) @dataclass diff --git a/pykeybasebot/types/gregor1/__init__.py b/pykeybasebot/types/gregor1/__init__.py index 4778748..d4eabc6 100644 --- a/pykeybasebot/types/gregor1/__init__.py +++ b/pykeybasebot/types/gregor1/__init__.py @@ -1,6 +1,6 @@ """gregor.1 -Auto-generated to Python types by avdl-compiler v1.4.4 (https://github.com/keybase/node-avdl-compiler) +Auto-generated to Python types by avdl-compiler v1.4.6 (https://github.com/keybase/node-avdl-compiler) Input files: - ../client/protocol/avdl/gregor1/auth.avdl - ../client/protocol/avdl/gregor1/auth_internal.avdl @@ -12,11 +12,9 @@ """ from dataclasses import dataclass, field -from enum import Enum -from typing import Dict, List, Optional, Union +from typing import List, Optional from dataclasses_json import DataClassJsonMixin, config -from typing_extensions import Literal DurationMsec = int DurationSec = int diff --git a/pykeybasebot/types/keybase1/__init__.py b/pykeybasebot/types/keybase1/__init__.py index 2d95923..35921e1 100644 --- a/pykeybasebot/types/keybase1/__init__.py +++ b/pykeybasebot/types/keybase1/__init__.py @@ -1,6 +1,6 @@ """keybase.1 -Auto-generated to Python types by avdl-compiler v1.4.4 (https://github.com/keybase/node-avdl-compiler) +Auto-generated to Python types by avdl-compiler v1.4.6 (https://github.com/keybase/node-avdl-compiler) Input files: - ../client/protocol/avdl/keybase1/account.avdl - ../client/protocol/avdl/keybase1/airdrop.avdl @@ -49,6 +49,7 @@ - ../client/protocol/avdl/keybase1/kex2provisionee.avdl - ../client/protocol/avdl/keybase1/kex2provisionee2.avdl - ../client/protocol/avdl/keybase1/kex2provisioner.avdl + - ../client/protocol/avdl/keybase1/kvstore.avdl - ../client/protocol/avdl/keybase1/log.avdl - ../client/protocol/avdl/keybase1/log_ui.avdl - ../client/protocol/avdl/keybase1/login.avdl @@ -770,6 +771,7 @@ class StatusCode(Enum): SCTeamWritePermDenied = 2625 SCTeamBadGeneration = 2636 SCNoOp = 2638 + SCTeamInviteBadCancel = 2645 SCTeamInviteBadToken = 2646 SCTeamTarDuplicate = 2663 SCTeamTarNotFound = 2664 @@ -803,6 +805,9 @@ class StatusCode(Enum): SCTeamProvisionalCanKey = 2721 SCTeamProvisionalCannotKey = 2722 SCTeamFTLOutdated = 2736 + SCTeamStorageWrongRevision = 2760 + SCTeamStorageBadGeneration = 2761 + SCTeamStorageNotFound = 2762 SCEphemeralKeyBadGeneration = 2900 SCEphemeralKeyUnexpectedBox = 2901 SCEphemeralKeyMissingBox = 2902 @@ -856,6 +861,7 @@ class StatusCode(Enum): SCTeambotKeyGenerationExists = 3800 SCTeambotKeyOldBoxedGeneration = 3801 SCTeambotKeyBadGeneration = 3802 + SCAirdropRegisterFailedMisc = 4207 class StatusCodeStrings(Enum): @@ -998,6 +1004,7 @@ class StatusCodeStrings(Enum): SCTeamWritePermDenied = "scteamwritepermdenied" SCTeamBadGeneration = "scteambadgeneration" SCNoOp = "scnoop" + SCTeamInviteBadCancel = "scteaminvitebadcancel" SCTeamInviteBadToken = "scteaminvitebadtoken" SCTeamTarDuplicate = "scteamtarduplicate" SCTeamTarNotFound = "scteamtarnotfound" @@ -1031,6 +1038,9 @@ class StatusCodeStrings(Enum): SCTeamProvisionalCanKey = "scteamprovisionalcankey" SCTeamProvisionalCannotKey = "scteamprovisionalcannotkey" SCTeamFTLOutdated = "scteamftloutdated" + SCTeamStorageWrongRevision = "scteamstoragewrongrevision" + SCTeamStorageBadGeneration = "scteamstoragebadgeneration" + SCTeamStorageNotFound = "scteamstoragenotfound" SCEphemeralKeyBadGeneration = "scephemeralkeybadgeneration" SCEphemeralKeyUnexpectedBox = "scephemeralkeyunexpectedbox" SCEphemeralKeyMissingBox = "scephemeralkeymissingbox" @@ -1084,6 +1094,7 @@ class StatusCodeStrings(Enum): SCTeambotKeyGenerationExists = "scteambotkeygenerationexists" SCTeambotKeyOldBoxedGeneration = "scteambotkeyoldboxedgeneration" SCTeambotKeyBadGeneration = "scteambotkeybadgeneration" + SCAirdropRegisterFailedMisc = "scairdropregisterfailedmisc" ED25519PublicKey = Optional[str] @@ -1680,6 +1691,52 @@ class PassphraseStream(DataClassJsonMixin): HelloRes = str +@dataclass +class KVGetResult(DataClassJsonMixin): + team_name: str = field(metadata=config(field_name="teamName")) + namespace: str = field(metadata=config(field_name="namespace")) + entry_key: str = field(metadata=config(field_name="entryKey")) + entry_value: str = field(metadata=config(field_name="entryValue")) + revision: int = field(metadata=config(field_name="revision")) + + +@dataclass +class KVPutResult(DataClassJsonMixin): + team_name: str = field(metadata=config(field_name="teamName")) + namespace: str = field(metadata=config(field_name="namespace")) + entry_key: str = field(metadata=config(field_name="entryKey")) + revision: int = field(metadata=config(field_name="revision")) + + +@dataclass +class EncryptedKVEntry(DataClassJsonMixin): + v: int = field(metadata=config(field_name="v")) + e: str = field(metadata=config(field_name="e")) + n: str = field(metadata=config(field_name="n")) + + +@dataclass +class KVListNamespaceResult(DataClassJsonMixin): + team_name: str = field(metadata=config(field_name="teamName")) + namespaces: Optional[Optional[List[str]]] = field( + default=None, metadata=config(field_name="namespaces") + ) + + +@dataclass +class KVListEntryKey(DataClassJsonMixin): + entry_key: str = field(metadata=config(field_name="entryKey")) + revision: int = field(metadata=config(field_name="revision")) + + +@dataclass +class KVDeleteEntryResult(DataClassJsonMixin): + team_name: str = field(metadata=config(field_name="teamName")) + namespace: str = field(metadata=config(field_name="namespace")) + entry_key: str = field(metadata=config(field_name="entryKey")) + revision: int = field(metadata=config(field_name="revision")) + + class ResetPromptType(Enum): COMPLETE = 0 ENTER_NO_DEVICES = 1 @@ -1699,6 +1756,18 @@ class ResetPromptInfo(DataClassJsonMixin): has_wallet: bool = field(metadata=config(field_name="hasWallet")) +class ResetPromptResponse(Enum): + NOTHING = 0 + CANCEL_RESET = 1 + CONFIRM_RESET = 2 + + +class ResetPromptResponseStrings(Enum): + NOTHING = "nothing" + CANCEL_RESET = "cancel_reset" + CONFIRM_RESET = "confirm_reset" + + class PassphraseRecoveryPromptType(Enum): ENCRYPTED_PGP_KEYS = 0 @@ -1707,6 +1776,26 @@ class PassphraseRecoveryPromptTypeStrings(Enum): ENCRYPTED_PGP_KEYS = "encrypted_pgp_keys" +class ResetMessage(Enum): + ENTERED_VERIFIED = 0 + ENTERED_PASSWORDLESS = 1 + REQUEST_VERIFIED = 2 + NOT_COMPLETED = 3 + CANCELED = 4 + COMPLETED = 5 + RESET_LINK_SENT = 6 + + +class ResetMessageStrings(Enum): + ENTERED_VERIFIED = "entered_verified" + ENTERED_PASSWORDLESS = "entered_passwordless" + REQUEST_VERIFIED = "request_verified" + NOT_COMPLETED = "not_completed" + CANCELED = "canceled" + COMPLETED = "completed" + RESET_LINK_SENT = "reset_link_sent" + + KBFSRootHash = str MerkleStoreSupportedVersion = int MerkleStoreKitHash = str @@ -1754,8 +1843,8 @@ class WalletAccountInfo(DataClassJsonMixin): @dataclass class NotificationChannels(DataClassJsonMixin): + pgp: bool = field(metadata=config(field_name="pgp")) session: bool = field(metadata=config(field_name="session")) - users: bool = field(metadata=config(field_name="users")) kbfs: bool = field(metadata=config(field_name="kbfs")) kbfsdesktop: bool = field(metadata=config(field_name="kbfsdesktop")) kbfslegacy: bool = field(metadata=config(field_name="kbfslegacy")) @@ -1767,7 +1856,7 @@ class NotificationChannels(DataClassJsonMixin): service: bool = field(metadata=config(field_name="service")) app: bool = field(metadata=config(field_name="app")) chat: bool = field(metadata=config(field_name="chat")) - pgp: bool = field(metadata=config(field_name="pgp")) + users: bool = field(metadata=config(field_name="users")) kbfsrequest: bool = field(metadata=config(field_name="kbfsrequest")) badges: bool = field(metadata=config(field_name="badges")) reachability: bool = field(metadata=config(field_name="reachability")) @@ -2119,6 +2208,7 @@ class ParamProofUsernameConfig(DataClassJsonMixin): class ParamProofLogoConfig(DataClassJsonMixin): svg_black: str = field(metadata=config(field_name="svg_black")) svg_full: str = field(metadata=config(field_name="svg_full")) + svg_white: str = field(metadata=config(field_name="svg_white")) @dataclass @@ -2569,6 +2659,7 @@ class TeamApplication(Enum): GIT_METADATA = 4 SEITAN_INVITE_TOKEN = 5 STELLAR_RELAY = 6 + KVSTORE = 7 class TeamApplicationStrings(Enum): @@ -2578,6 +2669,7 @@ class TeamApplicationStrings(Enum): GIT_METADATA = "git_metadata" SEITAN_INVITE_TOKEN = "seitan_invite_token" STELLAR_RELAY = "stellar_relay" + KVSTORE = "kvstore" class TeamStatus(Enum): @@ -2786,8 +2878,8 @@ class BulkRes(DataClassJsonMixin): @dataclass class TeamOperation(DataClassJsonMixin): + set_retention_policy: bool = field(metadata=config(field_name="setRetentionPolicy")) manage_members: bool = field(metadata=config(field_name="manageMembers")) - manage_subteams: bool = field(metadata=config(field_name="manageSubteams")) create_channel: bool = field(metadata=config(field_name="createChannel")) chat: bool = field(metadata=config(field_name="chat")) delete_channel: bool = field(metadata=config(field_name="deleteChannel")) @@ -2801,7 +2893,7 @@ class TeamOperation(DataClassJsonMixin): ) set_team_showcase: bool = field(metadata=config(field_name="setTeamShowcase")) set_member_showcase: bool = field(metadata=config(field_name="setMemberShowcase")) - set_retention_policy: bool = field(metadata=config(field_name="setRetentionPolicy")) + manage_subteams: bool = field(metadata=config(field_name="manageSubteams")) set_min_writer_role: bool = field(metadata=config(field_name="setMinWriterRole")) change_open_team: bool = field(metadata=config(field_name="changeOpenTeam")) leave_team: bool = field(metadata=config(field_name="leaveTeam")) @@ -3106,16 +3198,16 @@ class TeamIDWithVisibility(DataClassJsonMixin): @dataclass class PublicKey(DataClassJsonMixin): + device_id: DeviceID = field(metadata=config(field_name="deviceID")) kid: KID = field(metadata=config(field_name="KID")) - pgp_fingerprint: str = field(metadata=config(field_name="PGPFingerprint")) + e_time: Time = field(metadata=config(field_name="eTime")) is_sibkey: bool = field(metadata=config(field_name="isSibkey")) is_eldest: bool = field(metadata=config(field_name="isEldest")) parent_id: str = field(metadata=config(field_name="parentID")) - device_id: DeviceID = field(metadata=config(field_name="deviceID")) + pgp_fingerprint: str = field(metadata=config(field_name="PGPFingerprint")) device_description: str = field(metadata=config(field_name="deviceDescription")) device_type: str = field(metadata=config(field_name="deviceType")) c_time: Time = field(metadata=config(field_name="cTime")) - e_time: Time = field(metadata=config(field_name="eTime")) is_revoked: bool = field(metadata=config(field_name="isRevoked")) pgp_identities: Optional[Optional[List[PGPIdentity]]] = field( default=None, metadata=config(field_name="PGPIdentities") @@ -3220,14 +3312,14 @@ class ClientDetails(DataClassJsonMixin): @dataclass class Config(DataClassJsonMixin): + path: str = field(metadata=config(field_name="path")) server_uri: str = field(metadata=config(field_name="serverURI")) - socket_file: str = field(metadata=config(field_name="socketFile")) label: str = field(metadata=config(field_name="label")) run_mode: str = field(metadata=config(field_name="runMode")) gpg_exists: bool = field(metadata=config(field_name="gpgExists")) gpg_path: str = field(metadata=config(field_name="gpgPath")) version: str = field(metadata=config(field_name="version")) - path: str = field(metadata=config(field_name="path")) + socket_file: str = field(metadata=config(field_name="socketFile")) binary_realpath: str = field(metadata=config(field_name="binaryRealpath")) config_path: str = field(metadata=config(field_name="configPath")) version_short: str = field(metadata=config(field_name="versionShort")) @@ -3674,6 +3766,22 @@ class PerUserKeyBox(DataClassJsonMixin): receiver_kid: KID = field(metadata=config(field_name="receiver_kid")) +@dataclass +class KVEntryID(DataClassJsonMixin): + team_id: TeamID = field(metadata=config(field_name="teamID")) + namespace: str = field(metadata=config(field_name="namespace")) + entry_key: str = field(metadata=config(field_name="entryKey")) + + +@dataclass +class KVListEntryResult(DataClassJsonMixin): + team_name: str = field(metadata=config(field_name="teamName")) + namespace: str = field(metadata=config(field_name="namespace")) + entry_keys: Optional[Optional[List[KVListEntryKey]]] = field( + default=None, metadata=config(field_name="entryKeys") + ) + + @dataclass class ConfiguredAccount(DataClassJsonMixin): username: str = field(metadata=config(field_name="username")) @@ -3862,17 +3970,17 @@ class ParamProofJSON(DataClassJsonMixin): @dataclass class ParamProofServiceConfig(DataClassJsonMixin): + brand_color: str = field(metadata=config(field_name="brand_color")) version: int = field(metadata=config(field_name="version")) - domain: str = field(metadata=config(field_name="domain")) display_name: str = field(metadata=config(field_name="display_name")) + check_url: str = field(metadata=config(field_name="check_url")) description: str = field(metadata=config(field_name="description")) username_config: ParamProofUsernameConfig = field( metadata=config(field_name="username") ) - brand_color: str = field(metadata=config(field_name="brand_color")) + domain: str = field(metadata=config(field_name="domain")) prefill_url: str = field(metadata=config(field_name="prefill_url")) profile_url: str = field(metadata=config(field_name="profile_url")) - check_url: str = field(metadata=config(field_name="check_url")) logo: Optional[ParamProofLogoConfig] = field( default=None, metadata=config(field_name="logo") ) @@ -3896,6 +4004,9 @@ class ProveParameters(DataClassJsonMixin): logo_black: Optional[Optional[List[SizedImage]]] = field( default=None, metadata=config(field_name="logoBlack") ) + logo_white: Optional[Optional[List[SizedImage]]] = field( + default=None, metadata=config(field_name="logoWhite") + ) @dataclass @@ -4431,6 +4542,7 @@ class InterestingPerson(DataClassJsonMixin): uid: UID = field(metadata=config(field_name="uid")) username: str = field(metadata=config(field_name="username")) fullname: str = field(metadata=config(field_name="fullname")) + service_map: Dict[str, str] = field(metadata=config(field_name="serviceMap")) @dataclass @@ -4442,6 +4554,11 @@ class CanLogoutRes(DataClassJsonMixin): ) +@dataclass +class UserPassphraseStateMsg(DataClassJsonMixin): + passphrase_state: PassphraseState = field(metadata=config(field_name="state")) + + @dataclass class APIUserKeybaseResult(DataClassJsonMixin): username: str = field(metadata=config(field_name="username")) @@ -4553,13 +4670,13 @@ class Contact(DataClassJsonMixin): @dataclass class ProcessedContact(DataClassJsonMixin): + full_name: str = field(metadata=config(field_name="fullName")) contact_index: int = field(metadata=config(field_name="contactIndex")) - contact_name: str = field(metadata=config(field_name="contactName")) component: ContactComponent = field(metadata=config(field_name="component")) resolved: bool = field(metadata=config(field_name="resolved")) uid: UID = field(metadata=config(field_name="uid")) username: str = field(metadata=config(field_name="username")) - full_name: str = field(metadata=config(field_name="fullName")) + contact_name: str = field(metadata=config(field_name="contactName")) following: bool = field(metadata=config(field_name="following")) service_map: Dict[str, str] = field(metadata=config(field_name="serviceMap")) assertion: str = field(metadata=config(field_name="assertion")) @@ -4708,48 +4825,51 @@ class HomeScreenPeopleNotificationContactMulti(DataClassJsonMixin): @dataclass class Identify3Row(DataClassJsonMixin): gui_id: Identify3GUIID = field(metadata=config(field_name="guiID")) - key: str = field(metadata=config(field_name="key")) value: str = field(metadata=config(field_name="value")) priority: int = field(metadata=config(field_name="priority")) site_url: str = field(metadata=config(field_name="siteURL")) + key: str = field(metadata=config(field_name="key")) proof_url: str = field(metadata=config(field_name="proofURL")) sig_id: SigID = field(metadata=config(field_name="sigID")) ctime: Time = field(metadata=config(field_name="ctime")) state: Identify3RowState = field(metadata=config(field_name="state")) color: Identify3RowColor = field(metadata=config(field_name="color")) + kid: Optional[KID] = field(default=None, metadata=config(field_name="kid")) site_icon: Optional[Optional[List[SizedImage]]] = field( default=None, metadata=config(field_name="siteIcon") ) + metas: Optional[Optional[List[Identify3RowMeta]]] = field( + default=None, metadata=config(field_name="metas") + ) site_icon_full: Optional[Optional[List[SizedImage]]] = field( default=None, metadata=config(field_name="siteIconFull") ) - metas: Optional[Optional[List[Identify3RowMeta]]] = field( - default=None, metadata=config(field_name="metas") + site_icon_white: Optional[Optional[List[SizedImage]]] = field( + default=None, metadata=config(field_name="siteIconWhite") ) - kid: Optional[KID] = field(default=None, metadata=config(field_name="kid")) @dataclass class IdentifyOutcome(DataClassJsonMixin): + num_proof_failures: int = field(metadata=config(field_name="numProofFailures")) username: str = field(metadata=config(field_name="username")) + for_pgp_pull: bool = field(metadata=config(field_name="forPGPPull")) + track_options: TrackOptions = field(metadata=config(field_name="trackOptions")) track_status: TrackStatus = field(metadata=config(field_name="trackStatus")) num_track_failures: int = field(metadata=config(field_name="numTrackFailures")) num_track_changes: int = field(metadata=config(field_name="numTrackChanges")) - num_proof_failures: int = field(metadata=config(field_name="numProofFailures")) - num_revoked: int = field(metadata=config(field_name="numRevoked")) num_proof_successes: int = field(metadata=config(field_name="numProofSuccesses")) - track_options: TrackOptions = field(metadata=config(field_name="trackOptions")) - for_pgp_pull: bool = field(metadata=config(field_name="forPGPPull")) + num_revoked: int = field(metadata=config(field_name="numRevoked")) reason: IdentifyReason = field(metadata=config(field_name="reason")) status: Optional[Status] = field(default=None, metadata=config(field_name="status")) - warnings: Optional[Optional[List[str]]] = field( - default=None, metadata=config(field_name="warnings") + revoked: Optional[Optional[List[TrackDiff]]] = field( + default=None, metadata=config(field_name="revoked") ) track_used: Optional[TrackSummary] = field( default=None, metadata=config(field_name="trackUsed") ) - revoked: Optional[Optional[List[TrackDiff]]] = field( - default=None, metadata=config(field_name="revoked") + warnings: Optional[Optional[List[str]]] = field( + default=None, metadata=config(field_name="warnings") ) @@ -4789,14 +4909,14 @@ class CheckResult(DataClassJsonMixin): @dataclass class UserCard(DataClassJsonMixin): + website: str = field(metadata=config(field_name="website")) following: int = field(metadata=config(field_name="following")) - followers: int = field(metadata=config(field_name="followers")) uid: UID = field(metadata=config(field_name="uid")) full_name: str = field(metadata=config(field_name="fullName")) location: str = field(metadata=config(field_name="location")) bio: str = field(metadata=config(field_name="bio")) bio_decorated: str = field(metadata=config(field_name="bioDecorated")) - website: str = field(metadata=config(field_name="website")) + followers: int = field(metadata=config(field_name="followers")) twitter: str = field(metadata=config(field_name="twitter")) you_follow_them: bool = field(metadata=config(field_name="youFollowThem")) they_follow_you: bool = field(metadata=config(field_name="theyFollowYou")) @@ -4890,19 +5010,16 @@ class MetadataResponse(DataClassJsonMixin): @dataclass class BadgeState(DataClassJsonMixin): new_tlfs: int = field(metadata=config(field_name="newTlfs")) - rekeys_needed: int = field(metadata=config(field_name="rekeysNeeded")) + reset_state: ResetState = field(metadata=config(field_name="resetState")) new_followers: int = field(metadata=config(field_name="newFollowers")) inbox_vers: int = field(metadata=config(field_name="inboxVers")) home_todo_items: int = field(metadata=config(field_name="homeTodoItems")) unverified_emails: int = field(metadata=config(field_name="unverifiedEmails")) unverified_phones: int = field(metadata=config(field_name="unverifiedPhones")) - reset_state: ResetState = field(metadata=config(field_name="resetState")) + rekeys_needed: int = field(metadata=config(field_name="rekeysNeeded")) new_devices: Optional[Optional[List[DeviceID]]] = field( default=None, metadata=config(field_name="newDevices") ) - revoked_devices: Optional[Optional[List[DeviceID]]] = field( - default=None, metadata=config(field_name="revokedDevices") - ) conversations: Optional[Optional[List[BadgeConversationInfo]]] = field( default=None, metadata=config(field_name="conversations") ) @@ -4924,6 +5041,9 @@ class BadgeState(DataClassJsonMixin): unread_wallet_accounts: Optional[Optional[List[WalletAccountInfo]]] = field( default=None, metadata=config(field_name="unreadWalletAccounts") ) + revoked_devices: Optional[Optional[List[DeviceID]]] = field( + default=None, metadata=config(field_name="revokedDevices") + ) @dataclass @@ -5205,28 +5325,28 @@ class LinkTripleAndTime(DataClassJsonMixin): @dataclass class FastTeamSigChainState(DataClassJsonMixin): + per_team_key_seeds_verified: Dict[str, PerTeamKeySeed] = field( + metadata=config(field_name="perTeamKeySeedsVerified") + ) id: TeamID = field(metadata=config(field_name="ID")) - public: bool = field(metadata=config(field_name="public")) root_ancestor: TeamName = field(metadata=config(field_name="rootAncestor")) name_depth: int = field(metadata=config(field_name="nameDepth")) + link_i_ds: Dict[str, LinkID] = field(metadata=config(field_name="linkIDs")) per_team_keys: Dict[str, PerTeamKey] = field( metadata=config(field_name="perTeamKeys") ) - per_team_key_seeds_verified: Dict[str, PerTeamKeySeed] = field( - metadata=config(field_name="perTeamKeySeedsVerified") - ) + public: bool = field(metadata=config(field_name="public")) down_pointers: Dict[str, DownPointer] = field( metadata=config(field_name="downPointers") ) per_team_key_c_time: UnixTime = field(metadata=config(field_name="perTeamKeyCTime")) - link_i_ds: Dict[str, LinkID] = field(metadata=config(field_name="linkIDs")) merkle_info: Dict[str, MerkleRootV2] = field( metadata=config(field_name="merkleInfo") ) - last: Optional[LinkTriple] = field(default=None, metadata=config(field_name="last")) last_up_pointer: Optional[UpPointer] = field( default=None, metadata=config(field_name="lastUpPointer") ) + last: Optional[LinkTriple] = field(default=None, metadata=config(field_name="last")) @dataclass @@ -5398,8 +5518,8 @@ class MemberInfo(DataClassJsonMixin): @dataclass class AnnotatedMemberInfo(DataClassJsonMixin): + role: TeamRole = field(metadata=config(field_name="role")) user_id: UID = field(metadata=config(field_name="uid")) - team_id: TeamID = field(metadata=config(field_name="team_id")) username: str = field(metadata=config(field_name="username")) full_name: str = field(metadata=config(field_name="full_name")) fq_name: str = field(metadata=config(field_name="fq_name")) @@ -5408,14 +5528,14 @@ class AnnotatedMemberInfo(DataClassJsonMixin): metadata=config(field_name="implicit_team_display_name") ) is_open_team: bool = field(metadata=config(field_name="is_open_team")) - role: TeamRole = field(metadata=config(field_name="role")) + team_id: TeamID = field(metadata=config(field_name="team_id")) + is_member_showcased: bool = field(metadata=config(field_name="is_member_showcased")) needs_puk: bool = field(metadata=config(field_name="needsPUK")) member_count: int = field(metadata=config(field_name="member_count")) eldest_seqno: Seqno = field(metadata=config(field_name="member_eldest_seqno")) allow_profile_promote: bool = field( metadata=config(field_name="allow_profile_promote") ) - is_member_showcased: bool = field(metadata=config(field_name="is_member_showcased")) status: TeamMemberStatus = field(metadata=config(field_name="status")) implicit: Optional[ImplicitRole] = field( default=None, metadata=config(field_name="implicit") @@ -5528,6 +5648,9 @@ class ProofSuggestion(DataClassJsonMixin): profile_icon: Optional[Optional[List[SizedImage]]] = field( default=None, metadata=config(field_name="profileIcon") ) + profile_icon_white: Optional[Optional[List[SizedImage]]] = field( + default=None, metadata=config(field_name="profileIconWhite") + ) picker_icon: Optional[Optional[List[SizedImage]]] = field( default=None, metadata=config(field_name="pickerIcon") ) @@ -5557,16 +5680,16 @@ class ReferenceCountRes(DataClassJsonMixin): @dataclass class UserPlusKeys(DataClassJsonMixin): uid: UID = field(metadata=config(field_name="uid")) - username: str = field(metadata=config(field_name="username")) eldest_seqno: Seqno = field(metadata=config(field_name="eldestSeqno")) status: StatusCode = field(metadata=config(field_name="status")) + username: str = field(metadata=config(field_name="username")) pgp_key_count: int = field(metadata=config(field_name="pgpKeyCount")) uvv: UserVersionVector = field(metadata=config(field_name="uvv")) device_keys: Optional[Optional[List[PublicKey]]] = field( default=None, metadata=config(field_name="deviceKeys") ) - revoked_device_keys: Optional[Optional[List[RevokedKey]]] = field( - default=None, metadata=config(field_name="revokedDeviceKeys") + resets: Optional[Optional[List[ResetSummary]]] = field( + default=None, metadata=config(field_name="resets") ) deleted_device_keys: Optional[Optional[List[PublicKey]]] = field( default=None, metadata=config(field_name="deletedDeviceKeys") @@ -5574,16 +5697,16 @@ class UserPlusKeys(DataClassJsonMixin): per_user_keys: Optional[Optional[List[PerUserKey]]] = field( default=None, metadata=config(field_name="perUserKeys") ) - resets: Optional[Optional[List[ResetSummary]]] = field( - default=None, metadata=config(field_name="resets") + revoked_device_keys: Optional[Optional[List[RevokedKey]]] = field( + default=None, metadata=config(field_name="revokedDeviceKeys") ) @dataclass class ExtendedStatus(DataClassJsonMixin): standalone: bool = field(metadata=config(field_name="standalone")) - passphrase_stream_cached: bool = field( - metadata=config(field_name="passphraseStreamCached") + ui_router_mapping: Dict[str, int] = field( + metadata=config(field_name="uiRouterMapping") ) tsec_cached: bool = field(metadata=config(field_name="tsecCached")) device_sig_key_cached: bool = field( @@ -5597,34 +5720,31 @@ class ExtendedStatus(DataClassJsonMixin): stored_secret: bool = field(metadata=config(field_name="storedSecret")) secret_prompt_skip: bool = field(metadata=config(field_name="secretPromptSkip")) remember_passphrase: bool = field(metadata=config(field_name="rememberPassphrase")) - log_dir: str = field(metadata=config(field_name="logDir")) - default_username: str = field(metadata=config(field_name="defaultUsername")) - platform_info: PlatformInfo = field(metadata=config(field_name="platformInfo")) default_device_id: DeviceID = field(metadata=config(field_name="defaultDeviceID")) - ui_router_mapping: Dict[str, int] = field( - metadata=config(field_name="uiRouterMapping") - ) - device: Optional[Device] = field(default=None, metadata=config(field_name="device")) - device_err: Optional[LoadDeviceErr] = field( - default=None, metadata=config(field_name="deviceErr") + platform_info: PlatformInfo = field(metadata=config(field_name="platformInfo")) + log_dir: str = field(metadata=config(field_name="logDir")) + passphrase_stream_cached: bool = field( + metadata=config(field_name="passphraseStreamCached") ) - session: Optional[SessionStatus] = field( - default=None, metadata=config(field_name="session") + default_username: str = field(metadata=config(field_name="defaultUsername")) + local_db_stats: Optional[Optional[List[str]]] = field( + default=None, metadata=config(field_name="localDbStats") ) provisioned_usernames: Optional[Optional[List[str]]] = field( default=None, metadata=config(field_name="provisionedUsernames") ) - configured_accounts: Optional[Optional[List[ConfiguredAccount]]] = field( - default=None, metadata=config(field_name="configuredAccounts") - ) clients: Optional[Optional[List[ClientStatus]]] = field( default=None, metadata=config(field_name="Clients") ) device_ek_names: Optional[Optional[List[str]]] = field( default=None, metadata=config(field_name="deviceEkNames") ) - local_db_stats: Optional[Optional[List[str]]] = field( - default=None, metadata=config(field_name="localDbStats") + device_err: Optional[LoadDeviceErr] = field( + default=None, metadata=config(field_name="deviceErr") + ) + device: Optional[Device] = field(default=None, metadata=config(field_name="device")) + configured_accounts: Optional[Optional[List[ConfiguredAccount]]] = field( + default=None, metadata=config(field_name="configuredAccounts") ) local_chat_db_stats: Optional[Optional[List[str]]] = field( default=None, metadata=config(field_name="localChatDbStats") @@ -5638,6 +5758,9 @@ class ExtendedStatus(DataClassJsonMixin): cache_dir_size_info: Optional[Optional[List[DirSizeInfo]]] = field( default=None, metadata=config(field_name="cacheDirSizeInfo") ) + session: Optional[SessionStatus] = field( + default=None, metadata=config(field_name="session") + ) @dataclass @@ -5836,17 +5959,17 @@ class TeamMembersDetails(DataClassJsonMixin): @dataclass class FastTeamData(DataClassJsonMixin): + max_continuous_ptk_generation: PerTeamKeyGeneration = field( + metadata=config(field_name="maxContinuousPTKGeneration") + ) frozen: bool = field(metadata=config(field_name="frozen")) - subversion: int = field(metadata=config(field_name="subversion")) tombstoned: bool = field(metadata=config(field_name="tombstoned")) name: TeamName = field(metadata=config(field_name="name")) chain: FastTeamSigChainState = field(metadata=config(field_name="chain")) per_team_key_seeds_unverified: Dict[str, PerTeamKeySeed] = field( metadata=config(field_name="perTeamKeySeedsUnverified") ) - max_continuous_ptk_generation: PerTeamKeyGeneration = field( - metadata=config(field_name="maxContinuousPTKGeneration") - ) + subversion: int = field(metadata=config(field_name="subversion")) seed_checks: Dict[str, PerTeamSeedCheck] = field( metadata=config(field_name="seedChecks") ) @@ -5899,13 +6022,13 @@ class SeitanKeyAndLabel__V2(DataClassJsonMixin): @dataclass class LoadTeamArg(DataClassJsonMixin): + force_full_reload: bool = field(metadata=config(field_name="forceFullReload")) id: TeamID = field(metadata=config(field_name="ID")) - name: str = field(metadata=config(field_name="name")) public: bool = field(metadata=config(field_name="public")) need_admin: bool = field(metadata=config(field_name="needAdmin")) refresh_uid_mapper: bool = field(metadata=config(field_name="refreshUIDMapper")) refreshers: TeamRefreshers = field(metadata=config(field_name="refreshers")) - force_full_reload: bool = field(metadata=config(field_name="forceFullReload")) + name: str = field(metadata=config(field_name="name")) force_repoll: bool = field(metadata=config(field_name="forceRepoll")) stale_ok: bool = field(metadata=config(field_name="staleOK")) allow_name_lookup_burst_cache: bool = field( @@ -6031,6 +6154,9 @@ class NonUserDetails(DataClassJsonMixin): site_icon_full: Optional[Optional[List[SizedImage]]] = field( default=None, metadata=config(field_name="siteIconFull") ) + site_icon_white: Optional[Optional[List[SizedImage]]] = field( + default=None, metadata=config(field_name="siteIconWhite") + ) @dataclass @@ -6054,12 +6180,12 @@ class UserPlusAllKeys(DataClassJsonMixin): @dataclass class FullStatus(DataClassJsonMixin): + service: KbServiceStatus = field(metadata=config(field_name="service")) username: str = field(metadata=config(field_name="username")) - config_path: str = field(metadata=config(field_name="configPath")) cur_status: CurrentStatus = field(metadata=config(field_name="curStatus")) ext_status: ExtendedStatus = field(metadata=config(field_name="extStatus")) client: KbClientStatus = field(metadata=config(field_name="client")) - service: KbServiceStatus = field(metadata=config(field_name="service")) + config_path: str = field(metadata=config(field_name="configPath")) kbfs: KBFSStatus = field(metadata=config(field_name="kbfs")) desktop: DesktopStatus = field(metadata=config(field_name="desktop")) updater: UpdaterStatus = field(metadata=config(field_name="updater")) @@ -6201,17 +6327,17 @@ class TeamDetails(DataClassJsonMixin): @dataclass class HiddenTeamChain(DataClassJsonMixin): + last_per_team_keys: Dict[str, Seqno] = field( + metadata=config(field_name="lastPerTeamKeys") + ) id: TeamID = field(metadata=config(field_name="id")) - subversion: int = field(metadata=config(field_name="subversion")) public: bool = field(metadata=config(field_name="public")) frozen: bool = field(metadata=config(field_name="frozen")) tombstoned: bool = field(metadata=config(field_name="tombstoned")) last: Seqno = field(metadata=config(field_name="last")) last_full: Seqno = field(metadata=config(field_name="lastFull")) latest_seqno_hint: Seqno = field(metadata=config(field_name="latestSeqnoHint")) - last_per_team_keys: Dict[str, Seqno] = field( - metadata=config(field_name="lastPerTeamKeys") - ) + subversion: int = field(metadata=config(field_name="subversion")) outer: Dict[str, LinkID] = field(metadata=config(field_name="outer")) inner: Dict[str, HiddenTeamChainLink] = field(metadata=config(field_name="inner")) reader_per_team_keys: Dict[str, Seqno] = field( @@ -6229,25 +6355,29 @@ class HiddenTeamChain(DataClassJsonMixin): @dataclass class TeamSigChainState(DataClassJsonMixin): + per_team_keys: Dict[str, PerTeamKey] = field( + metadata=config(field_name="perTeamKeys") + ) reader: UserVersion = field(metadata=config(field_name="reader")) - id: TeamID = field(metadata=config(field_name="id")) implicit: bool = field(metadata=config(field_name="implicit")) public: bool = field(metadata=config(field_name="public")) root_ancestor: TeamName = field(metadata=config(field_name="rootAncestor")) name_depth: int = field(metadata=config(field_name="nameDepth")) + tlf_legacy_upgrade: Dict[str, TeamLegacyTLFUpgradeChainInfo] = field( + metadata=config(field_name="tlfLegacyUpgrade") + ) last_seqno: Seqno = field(metadata=config(field_name="lastSeqno")) last_link_id: LinkID = field(metadata=config(field_name="lastLinkID")) last_high_seqno: Seqno = field(metadata=config(field_name="lastHighSeqno")) last_high_link_id: LinkID = field(metadata=config(field_name="lastHighLinkID")) + bots: Dict[str, TeamBotSettings] = field(metadata=config(field_name="bots")) user_log: Dict[str, Optional[List[UserLogPoint]]] = field( metadata=config(field_name="userLog") ) subteam_log: Dict[str, Optional[List[SubteamLogPoint]]] = field( metadata=config(field_name="subteamLog") ) - per_team_keys: Dict[str, PerTeamKey] = field( - metadata=config(field_name="perTeamKeys") - ) + id: TeamID = field(metadata=config(field_name="id")) max_per_team_key_generation: PerTeamKeyGeneration = field( metadata=config(field_name="maxPerTeamKeyGeneration") ) @@ -6262,22 +6392,18 @@ class TeamSigChainState(DataClassJsonMixin): ) open: bool = field(metadata=config(field_name="open")) open_team_join_as: TeamRole = field(metadata=config(field_name="openTeamJoinAs")) - bots: Dict[str, TeamBotSettings] = field(metadata=config(field_name="bots")) - tlf_legacy_upgrade: Dict[str, TeamLegacyTLFUpgradeChainInfo] = field( - metadata=config(field_name="tlfLegacyUpgrade") - ) merkle_roots: Dict[str, MerkleRootV2] = field( metadata=config(field_name="merkleRoots") ) - name_log: Optional[Optional[List[TeamNameLogPoint]]] = field( - default=None, metadata=config(field_name="nameLog") - ) parent_id: Optional[TeamID] = field( default=None, metadata=config(field_name="parentID") ) tlf_i_ds: Optional[Optional[List[TLFID]]] = field( default=None, metadata=config(field_name="tlfIDs") ) + name_log: Optional[Optional[List[TeamNameLogPoint]]] = field( + default=None, metadata=config(field_name="nameLog") + ) head_merkle: Optional[MerkleRootV2] = field( default=None, metadata=config(field_name="headMerkle") ) @@ -6430,12 +6556,12 @@ class OpDescription__GET_REVISIONS(DataClassJsonMixin): @dataclass class TeamData(DataClassJsonMixin): + chain: TeamSigChainState = field(metadata=config(field_name="chain")) subversion: int = field(metadata=config(field_name="v")) - frozen: bool = field(metadata=config(field_name="frozen")) tombstoned: bool = field(metadata=config(field_name="tombstoned")) secretless: bool = field(metadata=config(field_name="secretless")) name: TeamName = field(metadata=config(field_name="name")) - chain: TeamSigChainState = field(metadata=config(field_name="chain")) + frozen: bool = field(metadata=config(field_name="frozen")) per_team_key_seeds_unverified: Dict[str, PerTeamKeySeedItem] = field( metadata=config(field_name="perTeamKeySeedsUnverified") ) @@ -6471,26 +6597,26 @@ class PublicKeyV2__PGP(DataClassJsonMixin): @dataclass class UserPlusKeysV2(DataClassJsonMixin): + device_keys: Dict[str, PublicKeyV2NaCl] = field( + metadata=config(field_name="deviceKeys") + ) uid: UID = field(metadata=config(field_name="uid")) - username: str = field(metadata=config(field_name="username")) eldest_seqno: Seqno = field(metadata=config(field_name="eldestSeqno")) status: StatusCode = field(metadata=config(field_name="status")) - device_keys: Dict[str, PublicKeyV2NaCl] = field( - metadata=config(field_name="deviceKeys") + remote_tracks: Dict[str, RemoteTrack] = field( + metadata=config(field_name="remoteTracks") ) + username: str = field(metadata=config(field_name="username")) pgp_keys: Dict[str, PublicKeyV2PGPSummary] = field( metadata=config(field_name="pgpKeys") ) - remote_tracks: Dict[str, RemoteTrack] = field( - metadata=config(field_name="remoteTracks") - ) unstubbed: bool = field(metadata=config(field_name="unstubbed")) - per_user_keys: Optional[Optional[List[PerUserKey]]] = field( - default=None, metadata=config(field_name="perUserKeys") - ) stellar_account_id: Optional[str] = field( default=None, metadata=config(field_name="stellarAccountID") ) + per_user_keys: Optional[Optional[List[PerUserKey]]] = field( + default=None, metadata=config(field_name="perUserKeys") + ) reset: Optional[ResetSummary] = field( default=None, metadata=config(field_name="reset") ) @@ -6698,6 +6824,14 @@ class FolderSyncConfigAndStatusWithFolder(DataClassJsonMixin): status: FolderSyncStatus = field(metadata=config(field_name="status")) +@dataclass +class FolderWithFavFlags(DataClassJsonMixin): + folder: Folder = field(metadata=config(field_name="folder")) + is_favorite: bool = field(metadata=config(field_name="isFavorite")) + is_ignored: bool = field(metadata=config(field_name="isIgnored")) + is_new: bool = field(metadata=config(field_name="isNew")) + + @dataclass class TLFBreak(DataClassJsonMixin): breaks: Optional[Optional[List[TLFIdentifyFailure]]] = field( diff --git a/pykeybasebot/types/stellar1/__init__.py b/pykeybasebot/types/stellar1/__init__.py index b69b1dd..38b4a2f 100644 --- a/pykeybasebot/types/stellar1/__init__.py +++ b/pykeybasebot/types/stellar1/__init__.py @@ -1,6 +1,6 @@ """stellar.1 -Auto-generated to Python types by avdl-compiler v1.4.4 (https://github.com/keybase/node-avdl-compiler) +Auto-generated to Python types by avdl-compiler v1.4.6 (https://github.com/keybase/node-avdl-compiler) Input files: - ../client/protocol/avdl/stellar1/bundle.avdl - ../client/protocol/avdl/stellar1/common.avdl @@ -114,21 +114,23 @@ class AccountBundleSecretUnsupported(DataClassJsonMixin): @dataclass class Asset(DataClassJsonMixin): + show_deposit_button: bool = field(metadata=config(field_name="showDepositButton")) type: str = field(metadata=config(field_name="type")) - code: str = field(metadata=config(field_name="code")) issuer: str = field(metadata=config(field_name="issuer")) verified_domain: str = field(metadata=config(field_name="verifiedDomain")) issuer_name: str = field(metadata=config(field_name="issuerName")) desc: str = field(metadata=config(field_name="desc")) info_url: str = field(metadata=config(field_name="infoUrl")) info_url_text: str = field(metadata=config(field_name="infoUrlText")) - show_deposit_button: bool = field(metadata=config(field_name="showDepositButton")) + code: str = field(metadata=config(field_name="code")) deposit_button_text: str = field(metadata=config(field_name="depositButtonText")) show_withdraw_button: bool = field(metadata=config(field_name="showWithdrawButton")) withdraw_button_text: str = field(metadata=config(field_name="withdrawButtonText")) withdraw_type: str = field(metadata=config(field_name="withdrawType")) transfer_server: str = field(metadata=config(field_name="transferServer")) auth_endpoint: str = field(metadata=config(field_name="authEndpoint")) + deposit_req_auth: bool = field(metadata=config(field_name="depositReqAuth")) + withdraw_req_auth: bool = field(metadata=config(field_name="withdrawReqAuth")) @dataclass @@ -547,8 +549,10 @@ class PaymentNotificationMsg(DataClassJsonMixin): @dataclass class AccountAssetLocal(DataClassJsonMixin): + available_to_send_worth: str = field( + metadata=config(field_name="availableToSendWorth") + ) name: str = field(metadata=config(field_name="name")) - asset_code: str = field(metadata=config(field_name="assetCode")) issuer_name: str = field(metadata=config(field_name="issuerName")) issuer_account_id: str = field(metadata=config(field_name="issuerAccountID")) issuer_verified_domain: str = field( @@ -560,15 +564,13 @@ class AccountAssetLocal(DataClassJsonMixin): ) worth_currency: str = field(metadata=config(field_name="worthCurrency")) worth: str = field(metadata=config(field_name="worth")) - available_to_send_worth: str = field( - metadata=config(field_name="availableToSendWorth") - ) + asset_code: str = field(metadata=config(field_name="assetCode")) + show_withdraw_button: bool = field(metadata=config(field_name="showWithdrawButton")) desc: str = field(metadata=config(field_name="desc")) info_url: str = field(metadata=config(field_name="infoUrl")) info_url_text: str = field(metadata=config(field_name="infoUrlText")) show_deposit_button: bool = field(metadata=config(field_name="showDepositButton")) deposit_button_text: str = field(metadata=config(field_name="depositButtonText")) - show_withdraw_button: bool = field(metadata=config(field_name="showWithdrawButton")) withdraw_button_text: str = field(metadata=config(field_name="withdrawButtonText")) reserves: Optional[Optional[List[AccountReserve]]] = field( default=None, metadata=config(field_name="reserves") @@ -630,19 +632,19 @@ class SendPaymentResLocal(DataClassJsonMixin): @dataclass class RequestDetailsLocal(DataClassJsonMixin): + amount: str = field(metadata=config(field_name="amount")) id: KeybaseRequestID = field(metadata=config(field_name="id")) - from_assertion: str = field(metadata=config(field_name="fromAssertion")) from_current_user: bool = field(metadata=config(field_name="fromCurrentUser")) to_user_type: ParticipantType = field(metadata=config(field_name="toUserType")) to_assertion: str = field(metadata=config(field_name="toAssertion")) - amount: str = field(metadata=config(field_name="amount")) - amount_description: str = field(metadata=config(field_name="amountDescription")) + from_assertion: str = field(metadata=config(field_name="fromAssertion")) worth_at_request_time: str = field(metadata=config(field_name="worthAtRequestTime")) + amount_description: str = field(metadata=config(field_name="amountDescription")) status: RequestStatus = field(metadata=config(field_name="status")) - asset: Optional[Asset] = field(default=None, metadata=config(field_name="asset")) currency: Optional[OutsideCurrencyCode] = field( default=None, metadata=config(field_name="currency") ) + asset: Optional[Asset] = field(default=None, metadata=config(field_name="asset")) @dataclass @@ -670,46 +672,46 @@ class SendResultCLILocal(DataClassJsonMixin): @dataclass class PaymentCLILocal(DataClassJsonMixin): + summary_advanced: str = field(metadata=config(field_name="summaryAdvanced")) tx_id: TransactionID = field(metadata=config(field_name="txID")) - time: TimeMs = field(metadata=config(field_name="time")) status: str = field(metadata=config(field_name="status")) status_detail: str = field(metadata=config(field_name="statusDetail")) amount: str = field(metadata=config(field_name="amount")) asset: Asset = field(metadata=config(field_name="asset")) + public_note_type: str = field(metadata=config(field_name="publicNoteType")) + public_note: str = field(metadata=config(field_name="publicNote")) source_amount_max: str = field(metadata=config(field_name="sourceAmountMax")) source_amount_actual: str = field(metadata=config(field_name="sourceAmountActual")) source_asset: Asset = field(metadata=config(field_name="sourceAsset")) is_advanced: bool = field(metadata=config(field_name="isAdvanced")) - summary_advanced: str = field(metadata=config(field_name="summaryAdvanced")) + time: TimeMs = field(metadata=config(field_name="time")) + unread: bool = field(metadata=config(field_name="unread")) from_stellar: AccountID = field(metadata=config(field_name="fromStellar")) - note: str = field(metadata=config(field_name="note")) note_err: str = field(metadata=config(field_name="noteErr")) - unread: bool = field(metadata=config(field_name="unread")) - public_note: str = field(metadata=config(field_name="publicNote")) - public_note_type: str = field(metadata=config(field_name="publicNoteType")) + note: str = field(metadata=config(field_name="note")) fee_charged_description: str = field( metadata=config(field_name="feeChargedDescription") ) - display_amount: Optional[str] = field( - default=None, metadata=config(field_name="displayAmount") + to_username: Optional[str] = field( + default=None, metadata=config(field_name="toUsername") ) - display_currency: Optional[str] = field( - default=None, metadata=config(field_name="displayCurrency") + to_assertion: Optional[str] = field( + default=None, metadata=config(field_name="toAssertion") ) - operations: Optional[Optional[List[str]]] = field( - default=None, metadata=config(field_name="operations") + from_username: Optional[str] = field( + default=None, metadata=config(field_name="fromUsername") ) to_stellar: Optional[AccountID] = field( default=None, metadata=config(field_name="toStellar") ) - from_username: Optional[str] = field( - default=None, metadata=config(field_name="fromUsername") + operations: Optional[Optional[List[str]]] = field( + default=None, metadata=config(field_name="operations") ) - to_username: Optional[str] = field( - default=None, metadata=config(field_name="toUsername") + display_currency: Optional[str] = field( + default=None, metadata=config(field_name="displayCurrency") ) - to_assertion: Optional[str] = field( - default=None, metadata=config(field_name="toAssertion") + display_amount: Optional[str] = field( + default=None, metadata=config(field_name="displayAmount") ) @@ -779,6 +781,7 @@ class PaymentDirectPost(DataClassJsonMixin): @dataclass class PaymentRelayPost(DataClassJsonMixin): + display_currency: str = field(metadata=config(field_name="displayCurrency")) from_device_id: keybase1.DeviceID = field( metadata=config(field_name="fromDeviceID") ) @@ -786,10 +789,9 @@ class PaymentRelayPost(DataClassJsonMixin): relay_account: AccountID = field(metadata=config(field_name="relayAccount")) team_id: keybase1.TeamID = field(metadata=config(field_name="teamID")) display_amount: str = field(metadata=config(field_name="displayAmount")) - display_currency: str = field(metadata=config(field_name="displayCurrency")) + quick_return: bool = field(metadata=config(field_name="quickReturn")) box_b_64: str = field(metadata=config(field_name="boxB64")) signed_transaction: str = field(metadata=config(field_name="signedTransaction")) - quick_return: bool = field(metadata=config(field_name="quickReturn")) batch_id: str = field(metadata=config(field_name="batchID")) to: Optional[keybase1.UserVersion] = field( default=None, metadata=config(field_name="to") @@ -835,8 +837,8 @@ class RelayOp(DataClassJsonMixin): @dataclass class PaymentSummaryDirect(DataClassJsonMixin): + from_display_amount: str = field(metadata=config(field_name="fromDisplayAmount")) kb_tx_id: KeybaseTransactionID = field(metadata=config(field_name="kbTxID")) - tx_id: TransactionID = field(metadata=config(field_name="txID")) tx_status: TransactionStatus = field(metadata=config(field_name="txStatus")) tx_err_msg: str = field(metadata=config(field_name="txErrMsg")) from_stellar: AccountID = field(metadata=config(field_name="fromStellar")) @@ -845,10 +847,13 @@ class PaymentSummaryDirect(DataClassJsonMixin): metadata=config(field_name="fromDeviceID") ) to_stellar: AccountID = field(metadata=config(field_name="toStellar")) + source_amount_actual: str = field(metadata=config(field_name="sourceAmountActual")) amount: str = field(metadata=config(field_name="amount")) asset: Asset = field(metadata=config(field_name="asset")) + source_amount_max: str = field(metadata=config(field_name="sourceAmountMax")) + from_airdrop: bool = field(metadata=config(field_name="fromAirdrop")) note_b_64: str = field(metadata=config(field_name="noteB64")) - from_display_amount: str = field(metadata=config(field_name="fromDisplayAmount")) + tx_id: TransactionID = field(metadata=config(field_name="txID")) from_display_currency: str = field( metadata=config(field_name="fromDisplayCurrency") ) @@ -860,18 +865,15 @@ class PaymentSummaryDirect(DataClassJsonMixin): unread: bool = field(metadata=config(field_name="unread")) from_primary: bool = field(metadata=config(field_name="fromPrimary")) batch_id: str = field(metadata=config(field_name="batchID")) - from_airdrop: bool = field(metadata=config(field_name="fromAirdrop")) - source_amount_max: str = field(metadata=config(field_name="sourceAmountMax")) - source_amount_actual: str = field(metadata=config(field_name="sourceAmountActual")) source_asset: Asset = field(metadata=config(field_name="sourceAsset")) - to: Optional[keybase1.UserVersion] = field( - default=None, metadata=config(field_name="to") + display_currency: Optional[str] = field( + default=None, metadata=config(field_name="displayCurrency") ) display_amount: Optional[str] = field( default=None, metadata=config(field_name="displayAmount") ) - display_currency: Optional[str] = field( - default=None, metadata=config(field_name="displayCurrency") + to: Optional[keybase1.UserVersion] = field( + default=None, metadata=config(field_name="to") ) @@ -911,23 +913,23 @@ class RequestPost(DataClassJsonMixin): @dataclass class RequestDetails(DataClassJsonMixin): id: KeybaseRequestID = field(metadata=config(field_name="id")) - from_user: keybase1.UserVersion = field(metadata=config(field_name="fromUser")) + status: RequestStatus = field(metadata=config(field_name="status")) + funding_kb_tx_id: KeybaseTransactionID = field( + metadata=config(field_name="fundingKbTxID") + ) to_assertion: str = field(metadata=config(field_name="toAssertion")) amount: str = field(metadata=config(field_name="amount")) + to_display_currency: str = field(metadata=config(field_name="toDisplayCurrency")) + from_user: keybase1.UserVersion = field(metadata=config(field_name="fromUser")) from_display_amount: str = field(metadata=config(field_name="fromDisplayAmount")) from_display_currency: str = field( metadata=config(field_name="fromDisplayCurrency") ) to_display_amount: str = field(metadata=config(field_name="toDisplayAmount")) - to_display_currency: str = field(metadata=config(field_name="toDisplayCurrency")) - funding_kb_tx_id: KeybaseTransactionID = field( - metadata=config(field_name="fundingKbTxID") - ) - status: RequestStatus = field(metadata=config(field_name="status")) + asset: Optional[Asset] = field(default=None, metadata=config(field_name="asset")) to_user: Optional[keybase1.UserVersion] = field( default=None, metadata=config(field_name="toUser") ) - asset: Optional[Asset] = field(default=None, metadata=config(field_name="asset")) currency: Optional[OutsideCurrencyCode] = field( default=None, metadata=config(field_name="currency") ) @@ -1056,13 +1058,13 @@ class StellarServerDefinitions(DataClassJsonMixin): @dataclass class WalletAccountLocal(DataClassJsonMixin): + account_mode: AccountMode = field(metadata=config(field_name="accountMode")) account_id: AccountID = field(metadata=config(field_name="accountID")) - is_default: bool = field(metadata=config(field_name="isDefault")) name: str = field(metadata=config(field_name="name")) balance_description: str = field(metadata=config(field_name="balanceDescription")) seqno: str = field(metadata=config(field_name="seqno")) currency_local: CurrencyLocal = field(metadata=config(field_name="currencyLocal")) - account_mode: AccountMode = field(metadata=config(field_name="accountMode")) + is_default: bool = field(metadata=config(field_name="isDefault")) account_mode_editable: bool = field( metadata=config(field_name="accountModeEditable") ) @@ -1074,9 +1076,8 @@ class WalletAccountLocal(DataClassJsonMixin): @dataclass class PaymentLocal(DataClassJsonMixin): - id: PaymentID = field(metadata=config(field_name="id")) tx_id: TransactionID = field(metadata=config(field_name="txID")) - time: TimeMs = field(metadata=config(field_name="time")) + id: PaymentID = field(metadata=config(field_name="id")) status_simplified: PaymentStatus = field( metadata=config(field_name="statusSimplified") ) @@ -1094,6 +1095,7 @@ class PaymentLocal(DataClassJsonMixin): from_account_id: AccountID = field(metadata=config(field_name="fromAccountID")) from_account_name: str = field(metadata=config(field_name="fromAccountName")) from_username: str = field(metadata=config(field_name="fromUsername")) + time: TimeMs = field(metadata=config(field_name="time")) to_account_name: str = field(metadata=config(field_name="toAccountName")) to_username: str = field(metadata=config(field_name="toUsername")) to_assertion: str = field(metadata=config(field_name="toAssertion")) @@ -1112,34 +1114,34 @@ class PaymentLocal(DataClassJsonMixin): batch_id: str = field(metadata=config(field_name="batchID")) from_airdrop: bool = field(metadata=config(field_name="fromAirdrop")) is_inflation: bool = field(metadata=config(field_name="isInflation")) - issuer_account_id: Optional[AccountID] = field( - default=None, metadata=config(field_name="issuerAccountID") - ) - to_account_id: Optional[AccountID] = field( - default=None, metadata=config(field_name="toAccountID") + trustline: Optional[PaymentTrustlineLocal] = field( + default=None, metadata=config(field_name="trustline") ) operations: Optional[Optional[List[str]]] = field( default=None, metadata=config(field_name="operations") ) + issuer_account_id: Optional[AccountID] = field( + default=None, metadata=config(field_name="issuerAccountID") + ) inflation_source: Optional[str] = field( default=None, metadata=config(field_name="inflationSource") ) - trustline: Optional[PaymentTrustlineLocal] = field( - default=None, metadata=config(field_name="trustline") + to_account_id: Optional[AccountID] = field( + default=None, metadata=config(field_name="toAccountID") ) @dataclass class BuildPaymentResLocal(DataClassJsonMixin): + worth_info: str = field(metadata=config(field_name="worthInfo")) ready_to_review: bool = field(metadata=config(field_name="readyToReview")) - from_: AccountID = field(metadata=config(field_name="from")) to_err_msg: str = field(metadata=config(field_name="toErrMsg")) amount_err_msg: str = field(metadata=config(field_name="amountErrMsg")) secret_note_err_msg: str = field(metadata=config(field_name="secretNoteErrMsg")) public_memo_err_msg: str = field(metadata=config(field_name="publicMemoErrMsg")) public_memo_override: str = field(metadata=config(field_name="publicMemoOverride")) worth_description: str = field(metadata=config(field_name="worthDescription")) - worth_info: str = field(metadata=config(field_name="worthInfo")) + from_: AccountID = field(metadata=config(field_name="from")) worth_amount: str = field(metadata=config(field_name="worthAmount")) worth_currency: str = field(metadata=config(field_name="worthCurrency")) display_amount_xlm: str = field(metadata=config(field_name="displayAmountXLM")) @@ -1227,20 +1229,23 @@ class OwnAccountCLILocal(DataClassJsonMixin): @dataclass class BatchResultLocal(DataClassJsonMixin): + wait_chat_duration_ms: TimeMs = field( + metadata=config(field_name="waitChatDurationMs") + ) start_time: TimeMs = field(metadata=config(field_name="startTime")) - prepared_time: TimeMs = field(metadata=config(field_name="preparedTime")) all_submitted_time: TimeMs = field(metadata=config(field_name="allSubmittedTime")) all_complete_time: TimeMs = field(metadata=config(field_name="allCompleteTime")) end_time: TimeMs = field(metadata=config(field_name="endTime")) + avg_relay_duration_ms: TimeMs = field( + metadata=config(field_name="avgRelayDurationMs") + ) overall_duration_ms: TimeMs = field(metadata=config(field_name="overallDurationMs")) prepare_duration_ms: TimeMs = field(metadata=config(field_name="prepareDurationMs")) submit_duration_ms: TimeMs = field(metadata=config(field_name="submitDurationMs")) wait_payments_duration_ms: TimeMs = field( metadata=config(field_name="waitPaymentsDurationMs") ) - wait_chat_duration_ms: TimeMs = field( - metadata=config(field_name="waitChatDurationMs") - ) + prepared_time: TimeMs = field(metadata=config(field_name="preparedTime")) count_success: int = field(metadata=config(field_name="countSuccess")) count_direct: int = field(metadata=config(field_name="countDirect")) count_relay: int = field(metadata=config(field_name="countRelay")) @@ -1253,9 +1258,6 @@ class BatchResultLocal(DataClassJsonMixin): avg_direct_duration_ms: TimeMs = field( metadata=config(field_name="avgDirectDurationMs") ) - avg_relay_duration_ms: TimeMs = field( - metadata=config(field_name="avgRelayDurationMs") - ) avg_error_duration_ms: TimeMs = field( metadata=config(field_name="avgErrorDurationMs") ) @@ -1266,15 +1268,15 @@ class BatchResultLocal(DataClassJsonMixin): @dataclass class ValidateStellarURIResultLocal(DataClassJsonMixin): + asset_code: str = field(metadata=config(field_name="assetCode")) operation: str = field(metadata=config(field_name="operation")) - origin_domain: str = field(metadata=config(field_name="originDomain")) message: str = field(metadata=config(field_name="message")) callback_url: str = field(metadata=config(field_name="callbackURL")) xdr: str = field(metadata=config(field_name="xdr")) summary: TxDisplaySummary = field(metadata=config(field_name="summary")) recipient: str = field(metadata=config(field_name="recipient")) amount: str = field(metadata=config(field_name="amount")) - asset_code: str = field(metadata=config(field_name="assetCode")) + origin_domain: str = field(metadata=config(field_name="originDomain")) asset_issuer: str = field(metadata=config(field_name="assetIssuer")) memo: str = field(metadata=config(field_name="memo")) memo_type: str = field(metadata=config(field_name="memoType")) @@ -1301,20 +1303,20 @@ class PaymentOp(DataClassJsonMixin): @dataclass class PaymentSummaryStellar(DataClassJsonMixin): + is_inflation: bool = field(metadata=config(field_name="isInflation")) tx_id: TransactionID = field(metadata=config(field_name="txID")) - from_: AccountID = field(metadata=config(field_name="from")) to: AccountID = field(metadata=config(field_name="to")) amount: str = field(metadata=config(field_name="amount")) asset: Asset = field(metadata=config(field_name="asset")) ctime: TimeMs = field(metadata=config(field_name="ctime")) cursor_token: str = field(metadata=config(field_name="cursorToken")) unread: bool = field(metadata=config(field_name="unread")) - is_inflation: bool = field(metadata=config(field_name="isInflation")) + from_: AccountID = field(metadata=config(field_name="from")) + summary_advanced: str = field(metadata=config(field_name="summaryAdvanced")) source_amount_max: str = field(metadata=config(field_name="sourceAmountMax")) source_amount_actual: str = field(metadata=config(field_name="sourceAmountActual")) source_asset: Asset = field(metadata=config(field_name="sourceAsset")) is_advanced: bool = field(metadata=config(field_name="isAdvanced")) - summary_advanced: str = field(metadata=config(field_name="summaryAdvanced")) inflation_source: Optional[str] = field( default=None, metadata=config(field_name="inflationSource") ) @@ -1328,8 +1330,8 @@ class PaymentSummaryStellar(DataClassJsonMixin): @dataclass class PaymentSummaryRelay(DataClassJsonMixin): + amount: str = field(metadata=config(field_name="amount")) kb_tx_id: KeybaseTransactionID = field(metadata=config(field_name="kbTxID")) - tx_id: TransactionID = field(metadata=config(field_name="txID")) tx_status: TransactionStatus = field(metadata=config(field_name="txStatus")) tx_err_msg: str = field(metadata=config(field_name="txErrMsg")) from_stellar: AccountID = field(metadata=config(field_name="fromStellar")) @@ -1337,28 +1339,28 @@ class PaymentSummaryRelay(DataClassJsonMixin): from_device_id: keybase1.DeviceID = field( metadata=config(field_name="fromDeviceID") ) + batch_id: str = field(metadata=config(field_name="batchID")) to_assertion: str = field(metadata=config(field_name="toAssertion")) relay_account: AccountID = field(metadata=config(field_name="relayAccount")) - amount: str = field(metadata=config(field_name="amount")) + tx_id: TransactionID = field(metadata=config(field_name="txID")) + cursor_token: str = field(metadata=config(field_name="cursorToken")) + team_id: keybase1.TeamID = field(metadata=config(field_name="teamID")) ctime: TimeMs = field(metadata=config(field_name="ctime")) rtime: TimeMs = field(metadata=config(field_name="rtime")) box_b_64: str = field(metadata=config(field_name="boxB64")) - team_id: keybase1.TeamID = field(metadata=config(field_name="teamID")) - cursor_token: str = field(metadata=config(field_name="cursorToken")) - batch_id: str = field(metadata=config(field_name="batchID")) from_airdrop: bool = field(metadata=config(field_name="fromAirdrop")) - to: Optional[keybase1.UserVersion] = field( - default=None, metadata=config(field_name="to") - ) - display_amount: Optional[str] = field( - default=None, metadata=config(field_name="displayAmount") - ) display_currency: Optional[str] = field( default=None, metadata=config(field_name="displayCurrency") ) claim: Optional[ClaimSummary] = field( default=None, metadata=config(field_name="claim") ) + display_amount: Optional[str] = field( + default=None, metadata=config(field_name="displayAmount") + ) + to: Optional[keybase1.UserVersion] = field( + default=None, metadata=config(field_name="to") + ) @dataclass diff --git a/pyproject.toml b/pyproject.toml index 32ff580..c05cb8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ black = {version = "^18.3-alpha.0", allows-prereleases = true} isort = "^4.3" flake8 = "^3.7" mypy = "^0.720.0" +pyotp = "^2.3" [tool.isort] combine_as_imports = true diff --git a/tests/fixtures/payment.json b/tests/fixtures/payment.json index a0bba3e..15c35fb 100644 --- a/tests/fixtures/payment.json +++ b/tests/fixtures/payment.json @@ -41,8 +41,10 @@ "infoUrlText": "", "showDepositButton": false, "depositButtonText": "", + "depositReqAuth": "", "showWithdrawButton": false, "withdrawButtonText": "", + "withdrawReqAuth": "", "withdrawType": "", "transferServer": "", "authEndpoint": "" From 1dfff3988ce68551c0308e93328e16e005a6d5bb Mon Sep 17 00:00:00 2001 From: M Mou Date: Mon, 28 Oct 2019 12:57:25 -0700 Subject: [PATCH 02/15] address PR comments --- examples/6_totp.py | 120 ++++++++++++++++++++------------- pykeybasebot/kvstore_client.py | 6 ++ 2 files changed, 79 insertions(+), 47 deletions(-) diff --git a/examples/6_totp.py b/examples/6_totp.py index 87b20f4..a34d8af 100644 --- a/examples/6_totp.py +++ b/examples/6_totp.py @@ -3,12 +3,28 @@ ################################### # WHAT IS IN THIS EXAMPLE? # -# This is a simple TOTP bot that makes use of -# the team encrypted key-value store, which -# we interact with using KVStoreClient. +# Keybase has added an encrypted key-value store intended to support +# security-conscious bot development with persistent state. It is a place to +# store small bits of data that are +# (1) encrypted for a team or user (via the user's implicit self-team: e.g. +# alice,alice), +# (2) persistent across logins +# (3) fast and durable. +# +# It supports putting, getting, listing and deleting. There is also a concurrency +# primitive, but check out the other example for that. A team has many +# namespaces, a namespace has many entryKeys, and an entryKey has one current +# entryValue. Namespaces and entryKeys are in cleartext, and the Keybase client +# service will encrypt and sign the entryValue on the way in (as well as +# decrypt and verify on the way out) so keybase servers cannot see it or lie +# about it. +# +# This example shows how you can build a simple TOTP bot that makes use of +# the team encrypted key-value store, which we interact with using KVStoreClient. ################################### import asyncio +import json import logging import os import sys @@ -39,30 +55,25 @@ class TotpHandler: TotpHandler handles commands sent via chat and uses the team key-value store to provide TOTP client functionality. - Background: The team encrypted key-value store stores (key, value) - pairs, with unique (team, namespace, key)s. The value is encrypted, and the - team and namespace are not. - TotpHandler listens to chat messages of the form: - `totp {provision|reprovision|remove|now|uri} ` and - `totp list` + `!totp {provision|reprovision|remove|now|uri} ` and + `!totp list` - For each provisioned key, the handler stores ("secret", <16 char base32 secret>) - in the team's KV store under the namespace of . This bot assumes - your team's KV store is solely used for TOTP credentials, though this can - easily be modified. + For each provisioned key, the handler stores in the namespace "totp" one + row, with the key "}". This Keybase bot can be used in place of MFA apps like Google Authenticator and Authy for login processes that require two-step verification. This bot uses the pyotp library, so it is also compatible with other MFA apps. A Keybase TOTP bot could be useful for logins where you're already using a strong - password over an secure transport, but are required to use TOTP. Or it could be - perfect for adding replay attack resistance to the physical keypad lock protecting - your team's ice cream freezer.... Note that depending on the login process and - how you use this bot with your Keybase team setup, this bot may not be appropriate - for threat models that require a physical "second factor" device. + password, but are required to also use TOTP. Or it could be perfect for adding + replay attack resistance to the physical keypad lock protecting your team's ice + cream freezer.... Note that depending on the login process and how you use this bot + with your Keybase team setup, this bot may not be appropriate for threat models + that require a physical "second factor" device. For more information on TOTP, see https://pyotp.readthedocs.io/en/latest/, https://tools.ietf.org/html/rfc6238. @@ -70,38 +81,46 @@ class TotpHandler: This example does minimal error handling. """ - MSG_PREFIX = "totp" + MSG_PREFIX = "!totp" + NAMESPACE = "totp" + + def to_json(self, secret): + return {"secret": secret} async def __provision(self, bot, team, issuer, force: bool = False): secret = pyotp.random_base32() + val = json.dumps(self.to_json(secret)) if not force: try: # throws exception if 1 is not the latest revision + 1 - await bot.kvstore.put(team, issuer, "secret", secret, revision=1) + await bot.kvstore.put(team, self.NAMESPACE, issuer, val, revision=1) except Exception as e: - res = await bot.kvstore.get(team, issuer, "secret") - if res.revision > 0 and res.entry_value == "": + res = await bot.kvstore.get(team, self.NAMESPACE, issuer) + if bot.kvstore.is_deleted(res): # then TOTP for this issuer was previously provisioned but # was deleted. insert as latest revision + 1 - await bot.kvstore.put(team, issuer, "secret", secret) + await bot.kvstore.put(team, self.NAMESPACE, issuer) else: # then TOTP for this issuer was previously provisioned and # might still be in use, so raise the exception raise e else: # reprovisioning; "force" provisioning by inserting as latest revision + 1 - await bot.kvstore.put(team, issuer, "secret", secret) + await bot.kvstore.put(team, self.NAMESPACE, issuer, val) return pyotp.TOTP(secret).provisioning_uri(team, issuer_name=issuer) async def __remove(self, bot, team, issuer): # throws exception if nothing to delete - await bot.kvstore.delete(team, issuer, "secret") + await bot.kvstore.delete(team, self.NAMESPACE, issuer) async def __list(self, bot, team): # returns all namespaces in this team; assumes that all namespaces are # used for storing TOTP credentials - res = await bot.kvstore.list_namespaces(team) - return res.namespaces # list of namespaces + res = await bot.kvstore.list_entrykeys(team, self.NAMESPACE) + if res.entry_keys: + return [e.entry_key for e in res.entry_keys] + else: + return [] async def __uri(self, bot, team, issuer): totp = await self.__totp(bot, team, issuer) @@ -112,23 +131,32 @@ async def __now(self, bot, team, issuer): return totp.now() if totp else None async def __totp(self, bot, team, issuer): - secret = await bot.kvstore.get(team, issuer, "secret") - if secret.revision > 0 and secret.entry_value != "": + res = await bot.kvstore.get(team, self.NAMESPACE, issuer) + if bot.kvstore.is_present(res): # if secret is present - return pyotp.TOTP(secret.entry_value) + secret = json.loads(res.entry_value)["secret"] + return pyotp.TOTP(secret) else: return None async def __call__(self, bot, event): - if event.type != EventType.CHAT or event.msg.channel.members_type != "team": + members_type = event.msg.channel.members_type + if not ( + event.type == EventType.CHAT + and (members_type == "team" or members_type == "impteamnative") + ): return channel = event.msg.channel - team = channel.name + + # support teams and implicit self teams + team = ( + channel.name if members_type == "team" else "{0},{0}".format(channel.name) + ) msg = event.msg.content.text.body.split(" ") if len(msg) == 2 and msg[0] == self.MSG_PREFIX and msg[1] == TotpMsg.LIST.value: - # chat: "totp list" + # chat: "!totp list" ns = await self.__list(bot, team) await bot.chat.send(channel, str(ns)) return @@ -138,7 +166,7 @@ async def __call__(self, bot, event): action, issuer = msg[1], msg[2] if action == TotpMsg.PROVISION.value: - # chat: "totp provision " + # chat: "!totp provision " send_msg = "Error provisioning TOTP for {0}. If this issuer was previously provisioned, confirm reprovisioning with command `totp {1} {0}`.".format( issuer, TotpMsg.REPROVISION.value ) @@ -147,26 +175,26 @@ async def __call__(self, bot, event): send_msg = "TOTP provisioned for {}. provisioning_uri={}".format( issuer, uri ) - except Exception: - pass + except Exception as e: + print(e) finally: await bot.chat.send(channel, send_msg) return if action == TotpMsg.REPROVISION.value: - # chat: "totp reprovision " + # chat: "!totp reprovision " send_msg = "Error reprovisioning TOTP for {}".format(issuer) try: uri = await self.__provision(bot, team, issuer, force=True) send_msg = "TOTP reprovisioned for {}. provisioning_uri={}".format( issuer, uri ) - except Exception: - pass + except Exception as e: + print(e) finally: await bot.chat.send(channel, send_msg) return if action == TotpMsg.NOW.value: - # chat: "totp now " + # chat: "!totp now " send_msg = "Error getting current TOTP for {}".format(issuer) code = await self.__now(bot, team, issuer) if code: @@ -174,7 +202,7 @@ async def __call__(self, bot, event): await bot.chat.send(channel, send_msg) return if action == TotpMsg.URI.value: - # chat: "totp uri " + # chat: "!totp uri " send_msg = "Error getting provisioning_uri for {}".format(issuer) uri = await self.__uri(bot, team, issuer) if uri: @@ -182,13 +210,13 @@ async def __call__(self, bot, event): await bot.chat.send(channel, send_msg) return if action == TotpMsg.REMOVE.value: - # chat: "totp remove " + # chat: "!totp remove " send_msg = "No keys to remove for {}".format(issuer) try: await self.__remove(bot, team, issuer) send_msg = "Removed TOTP keys for {}".format(issuer) - except Exception: - pass + except Exception as e: + print(e) finally: await bot.chat.send(channel, send_msg) return @@ -201,6 +229,4 @@ async def __call__(self, bot, event): username=username, paperkey=os.environ["KEYBASE_PAPERKEY"], handler=TotpHandler() ) -listen_options = {"filter-channels": [{"name": team, "members_type": "team"}]} - -asyncio.run(bot.start(listen_options)) +asyncio.run(bot.start({})) diff --git a/pykeybasebot/kvstore_client.py b/pykeybasebot/kvstore_client.py index a2c33e3..b85dfbc 100644 --- a/pykeybasebot/kvstore_client.py +++ b/pykeybasebot/kvstore_client.py @@ -89,6 +89,12 @@ async def list_entrykeys( ) return keybase1.KVListEntryResult.from_dict(res) + def is_deleted(self, res: keybase1.KVGetResult) -> bool: + return res.revision > 0 and res.entry_value == "" + + def is_present(self, res: keybase1.KVGetResult) -> bool: + return res.revision > 0 and res.entry_value != "" + async def execute(self, command): resp = await self.bot.submit("kvstore api", json.dumps(command).encode("utf-8")) return resp["result"] From 929b07523c0d853391bba747b83155e44976b15f Mon Sep 17 00:00:00 2001 From: M Mou Date: Mon, 28 Oct 2019 15:56:44 -0700 Subject: [PATCH 03/15] add 4_simple_storage example --- examples/4_simple_storage.py | 134 ++++++++++++++++++++++ examples/{6_totp.py => 4_totp_storage.py} | 22 +--- 2 files changed, 138 insertions(+), 18 deletions(-) create mode 100644 examples/4_simple_storage.py rename examples/{6_totp.py => 4_totp_storage.py} (90%) diff --git a/examples/4_simple_storage.py b/examples/4_simple_storage.py new file mode 100644 index 0000000..aca566b --- /dev/null +++ b/examples/4_simple_storage.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 + +################################### +# WHAT IS IN THIS EXAMPLE? +# +# Keybase has added an encrypted key-value store intended to support +# security-conscious bot development with persistent state. It is a place to +# store small bits of data that are +# (1) encrypted for a team or user (via the user's implicit self-team: e.g. +# alice,alice), +# (2) persistent across logins +# (3) fast and durable. +# +# It supports putting, getting, listing and deleting. There is also a concurrency +# primitive, but check out the other example for that. A team has many +# namespaces, a namespace has many entryKeys, and an entryKey has one current +# entryValue. Namespaces and entryKeys are in cleartext, and the Keybase client +# service will encrypt and sign the entryValue on the way in (as well as +# decrypt and verify on the way out) so keybase servers cannot see it or lie +# about it. +# +# This example shows how you can use KVStoreClient to interact with the team +# encrypted key-value store. +# +# This example propagates errors to the chat user and is not concurrency safe. +################################### + +import asyncio +import logging +import os +import sys +from enum import Enum + +from pykeybasebot import Bot, EventType + +logging.basicConfig(level=logging.DEBUG) + +if "win32" in sys.platform: + # Windows specific event-loop policy + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + +class KVMsg(Enum): + PUT = "put" + GET = "get" + DELETE = "delete" + LIST = "list" + + +class KVHandler: + """ + KVHandler handles commands sent via chat to use the team key-value store. + + KVHandler listens to chat messages of the form: + `!storage put ()` + `!storage {get|delete} ` + `!storage list` + `!storage list ` + + It expects properly formatted commands. + """ + + MSG_PREFIX = "!storage" + + async def __call__(self, bot, event): + members_type = event.msg.channel.members_type + if not ( + event.type == EventType.CHAT + and (members_type == "team" or members_type == "impteamnative") + ): + return + + channel = event.msg.channel + + # support teams and implicit self teams + team = ( + channel.name if members_type == "team" else "{0},{0}".format(channel.name) + ) + + msg = event.msg.content.text.body.split(" ") + if msg[0] != self.MSG_PREFIX: + return + + if len(msg) == 2 and msg[1] == KVMsg.LIST.value: + # !storage list + res = await bot.kvstore.list_namespaces(team) + await bot.chat.send(channel, str(res)) + return + if len(msg) == 3 and msg[1] == KVMsg.LIST.value: + # !storage list + namespace = msg[2] + res = await bot.kvstore.list_entrykeys(team, namespace) + await bot.chat.send(channel, str(res)) + return + if len(msg) == 4: + (action, namespace, key) = (msg[1], msg[2], msg[3]) + if action == KVMsg.GET.value: + # !storage get + res = await bot.kvstore.get(team, namespace, key) + await bot.chat.send(channel, str(res)) + return + if action == KVMsg.DELETE.value: + # !storage delete + try: + res = await bot.kvstore.delete(team, namespace, key) + await bot.chat.send(channel, str(res)) + except Exception as e: + await bot.chat.send(channel, str(e)) + return + if len(msg) == 5 or len(msg) == 6: + # !storage put () + (action, namespace, key, value) = (msg[1], msg[2], msg[3], msg[4]) + revision = int(msg[5]) if len(msg) == 6 else None + if action == KVMsg.PUT.value: + try: + res = await bot.kvstore.put( + team, namespace, key, value, revision=revision + ) + await bot.chat.send(channel, str(res)) + except Exception as e: + await bot.chat.send(channel, str(e)) + return + + +username = "user3" # "yourbot" + +bot = Bot( + username=username, + paperkey=os.environ["KEYBASE_PAPERKEY"], + handler=KVHandler(), + keybase="/home/user/Documents/repos/go/src/github.com/keybase/client/go/keybase/keybase", +) + +asyncio.run(bot.start({})) diff --git a/examples/6_totp.py b/examples/4_totp_storage.py similarity index 90% rename from examples/6_totp.py rename to examples/4_totp_storage.py index a34d8af..7b408d4 100644 --- a/examples/6_totp.py +++ b/examples/4_totp_storage.py @@ -3,24 +3,13 @@ ################################### # WHAT IS IN THIS EXAMPLE? # -# Keybase has added an encrypted key-value store intended to support -# security-conscious bot development with persistent state. It is a place to -# store small bits of data that are -# (1) encrypted for a team or user (via the user's implicit self-team: e.g. -# alice,alice), -# (2) persistent across logins -# (3) fast and durable. -# -# It supports putting, getting, listing and deleting. There is also a concurrency -# primitive, but check out the other example for that. A team has many -# namespaces, a namespace has many entryKeys, and an entryKey has one current -# entryValue. Namespaces and entryKeys are in cleartext, and the Keybase client -# service will encrypt and sign the entryValue on the way in (as well as -# decrypt and verify on the way out) so keybase servers cannot see it or lie -# about it. +# Keybase has added an encrypted key-value store; see 4_simple_storage.py for +# more information. # # This example shows how you can build a simple TOTP bot that makes use of # the team encrypted key-value store, which we interact with using KVStoreClient. +# +# This example does minimal error handling and is not concurrency safe. ################################### import asyncio @@ -77,8 +66,6 @@ class TotpHandler: For more information on TOTP, see https://pyotp.readthedocs.io/en/latest/, https://tools.ietf.org/html/rfc6238. - - This example does minimal error handling. """ MSG_PREFIX = "!totp" @@ -223,7 +210,6 @@ async def __call__(self, bot, event): username = "yourbot" -team = "yourtotpbotteam" bot = Bot( username=username, paperkey=os.environ["KEYBASE_PAPERKEY"], handler=TotpHandler() From 8bb7ec5fba504145b0746a926a2d2e4c89dbfd30 Mon Sep 17 00:00:00 2001 From: M Mou Date: Tue, 29 Oct 2019 13:53:31 -0700 Subject: [PATCH 04/15] fixes --- examples/4_simple_storage.py | 10 +-- examples/4_totp_storage.py | 162 ++++++++++++++--------------------- 2 files changed, 67 insertions(+), 105 deletions(-) diff --git a/examples/4_simple_storage.py b/examples/4_simple_storage.py index aca566b..83dc6e5 100644 --- a/examples/4_simple_storage.py +++ b/examples/4_simple_storage.py @@ -113,6 +113,9 @@ async def __call__(self, bot, event): revision = int(msg[5]) if len(msg) == 6 else None if action == KVMsg.PUT.value: try: + # note: if revision=None, the server does a get (to get + # the latest revision number) then a put (with revision + # number + 1). this operation is not atomic. res = await bot.kvstore.put( team, namespace, key, value, revision=revision ) @@ -122,13 +125,10 @@ async def __call__(self, bot, event): return -username = "user3" # "yourbot" +username = "yourbot" bot = Bot( - username=username, - paperkey=os.environ["KEYBASE_PAPERKEY"], - handler=KVHandler(), - keybase="/home/user/Documents/repos/go/src/github.com/keybase/client/go/keybase/keybase", + username=username, paperkey=os.environ["KEYBASE_PAPERKEY"], handler=KVHandler() ) asyncio.run(bot.start({})) diff --git a/examples/4_totp_storage.py b/examples/4_totp_storage.py index 7b408d4..da5ebfc 100644 --- a/examples/4_totp_storage.py +++ b/examples/4_totp_storage.py @@ -31,12 +31,11 @@ class TotpMsg(Enum): - PROVISION = "provision" - REPROVISION = "reprovision" + ADD = "add" REMOVE = "remove" NOW = "now" - URI = "uri" LIST = "list" + HELP = "help" class TotpHandler: @@ -46,8 +45,9 @@ class TotpHandler: TotpHandler listens to chat messages of the form: - `!totp {provision|reprovision|remove|now|uri} ` and - `!totp list` + `!totp add ` + `!totp {now|remove} ` + `!totp {list|help}` For each provisioned key, the handler stores in the namespace "totp" one row, with the key "" - send_msg = "Error provisioning TOTP for {0}. If this issuer was previously provisioned, confirm reprovisioning with command `totp {1} {0}`.".format( - issuer, TotpMsg.REPROVISION.value - ) - try: - uri = await self.__provision(bot, team, issuer, force=False) - send_msg = "TOTP provisioned for {}. provisioning_uri={}".format( - issuer, uri - ) - except Exception as e: - print(e) - finally: + if len(msg) == 2: + action = msg[1] + if action == TotpMsg.LIST.value: + # chat: "!totp list" + ns = await self.list(bot, team) + await bot.chat.send(channel, str(ns)) + return + if action == TotpMsg.HELP.value: + # chat: "!totp help" + send_msg = "Available commands:\ + \n`!totp add `\ + \n`!totp {now|remove} `\ + \n`!totp {list|help}`" await bot.chat.send(channel, send_msg) - return - if action == TotpMsg.REPROVISION.value: - # chat: "!totp reprovision " - send_msg = "Error reprovisioning TOTP for {}".format(issuer) - try: - uri = await self.__provision(bot, team, issuer, force=True) - send_msg = "TOTP reprovisioned for {}. provisioning_uri={}".format( - issuer, uri - ) - except Exception as e: - print(e) - finally: + return + + if len(msg) == 3: + action, issuer = msg[1], msg[2] + if action == TotpMsg.NOW.value: + # chat: "!totp now " + send_msg = "Error getting current TOTP for {}".format(issuer) + code = await self.now(bot, team, issuer) + if code: + send_msg = "Current TOTP for {}: {}".format(issuer, code) await bot.chat.send(channel, send_msg) - return - if action == TotpMsg.NOW.value: - # chat: "!totp now " - send_msg = "Error getting current TOTP for {}".format(issuer) - code = await self.__now(bot, team, issuer) - if code: - send_msg = "Current TOTP for {}: {}".format(issuer, code) - await bot.chat.send(channel, send_msg) - return - if action == TotpMsg.URI.value: - # chat: "!totp uri " - send_msg = "Error getting provisioning_uri for {}".format(issuer) - uri = await self.__uri(bot, team, issuer) - if uri: - send_msg = uri - await bot.chat.send(channel, send_msg) - return - if action == TotpMsg.REMOVE.value: - # chat: "!totp remove " - send_msg = "No keys to remove for {}".format(issuer) - try: - await self.__remove(bot, team, issuer) - send_msg = "Removed TOTP keys for {}".format(issuer) - except Exception as e: - print(e) - finally: - await bot.chat.send(channel, send_msg) - return + return + if action == TotpMsg.REMOVE.value: + # chat: "!totp remove " + send_msg = "No keys to remove for {}".format(issuer) + try: + await self.remove(bot, team, issuer) + send_msg = "Removed TOTP keys for {}".format(issuer) + except Exception as e: + print(e) + finally: + await bot.chat.send(channel, send_msg) + return + + if len(msg) == 4: + action, issuer, secret = msg[1], msg[2], msg[3] + if action == TotpMsg.ADD.value: + # chat: "!totp add " + send_msg = "Error adding TOTP for {0}".format(issuer) + try: + await self.add(bot, team, issuer, secret) + send_msg = "TOTP added for {}".format(issuer) + except Exception as e: + print(e) + finally: + await bot.chat.send(channel, send_msg) + return username = "yourbot" From e7887bd6d0f0081dd5db80701825909abf0e56fe Mon Sep 17 00:00:00 2001 From: M Mou Date: Sat, 2 Nov 2019 17:01:37 -0700 Subject: [PATCH 05/15] wip --- .pre-commit-config.yaml | 2 +- examples/0_echo_everything.py | 4 +- examples/1_clap_for_everything.py | 4 +- examples/1_pingpong.py | 4 +- examples/2_get_paid.py | 4 +- examples/3_async_locking.py | 4 +- examples/3_poll_with_reactions.py | 4 +- examples/3_scrolling_messages.py | 4 +- examples/4_framework.py | 4 +- examples/4_secret_storage.py | 376 ++++++++++++++++++++++++ examples/4_simple_storage.py | 39 ++- examples/4_totp_storage.py | 9 +- examples/5_customclient.py | 4 +- poetry.lock | 36 ++- pykeybasebot/errors.py | 14 + pykeybasebot/kvstore_client.py | 23 +- pykeybasebot/types/chat1/__init__.py | 3 + pykeybasebot/types/keybase1/__init__.py | 35 ++- 18 files changed, 543 insertions(+), 30 deletions(-) create mode 100644 examples/4_secret_storage.py create mode 100644 pykeybasebot/errors.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0cf9947..8c8d3c3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,6 @@ repos: name: mypy stages: [commit] language: system - entry: poetry run mypy pykeybasebot/ + entry: poetry run mypy pykeybasebot/ examples/ types: [python] pass_filenames: false diff --git a/examples/0_echo_everything.py b/examples/0_echo_everything.py index 7220fb2..4abb4c4 100644 --- a/examples/0_echo_everything.py +++ b/examples/0_echo_everything.py @@ -20,7 +20,9 @@ if "win32" in sys.platform: # Windows specific event-loop policy - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + asyncio.set_event_loop_policy( + asyncio.WindowsProactorEventLoopPolicy() # type: ignore + ) class Handler: diff --git a/examples/1_clap_for_everything.py b/examples/1_clap_for_everything.py index 5cfb6f0..5aefe84 100644 --- a/examples/1_clap_for_everything.py +++ b/examples/1_clap_for_everything.py @@ -18,7 +18,9 @@ if "win32" in sys.platform: # Windows specific event-loop policy - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + asyncio.set_event_loop_policy( + asyncio.WindowsProactorEventLoopPolicy() # type: ignore + ) async def handler(bot, event): diff --git a/examples/1_pingpong.py b/examples/1_pingpong.py index 6724551..9f7f597 100644 --- a/examples/1_pingpong.py +++ b/examples/1_pingpong.py @@ -20,7 +20,9 @@ if "win32" in sys.platform: # Windows specific event-loop policy - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + asyncio.set_event_loop_policy( + asyncio.WindowsProactorEventLoopPolicy() # type: ignore + ) class Handler: diff --git a/examples/2_get_paid.py b/examples/2_get_paid.py index 56399eb..6a0bc1d 100644 --- a/examples/2_get_paid.py +++ b/examples/2_get_paid.py @@ -19,7 +19,9 @@ if "win32" in sys.platform: # Windows specific event-loop policy - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + asyncio.set_event_loop_policy( + asyncio.WindowsProactorEventLoopPolicy() # type: ignore + ) DollarsPerLumen = 0.06 diff --git a/examples/3_async_locking.py b/examples/3_async_locking.py index 5c83129..0395181 100644 --- a/examples/3_async_locking.py +++ b/examples/3_async_locking.py @@ -22,7 +22,9 @@ if "win32" in sys.platform: # Windows specific event-loop policy - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + asyncio.set_event_loop_policy( + asyncio.WindowsProactorEventLoopPolicy() # type: ignore + ) async def alert(bot, wait_sec, channel): diff --git a/examples/3_poll_with_reactions.py b/examples/3_poll_with_reactions.py index c38ab31..d26a39e 100644 --- a/examples/3_poll_with_reactions.py +++ b/examples/3_poll_with_reactions.py @@ -21,7 +21,9 @@ if "win32" in sys.platform: # Windows specific event-loop policy - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + asyncio.set_event_loop_policy( + asyncio.WindowsProactorEventLoopPolicy() # type: ignore + ) async def make_a_poll(): diff --git a/examples/3_scrolling_messages.py b/examples/3_scrolling_messages.py index 1923bcb..426e402 100644 --- a/examples/3_scrolling_messages.py +++ b/examples/3_scrolling_messages.py @@ -21,7 +21,9 @@ if "win32" in sys.platform: # Windows specific event-loop policy - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + asyncio.set_event_loop_policy( + asyncio.WindowsProactorEventLoopPolicy() # type: ignore + ) def rotate(message): diff --git a/examples/4_framework.py b/examples/4_framework.py index d9ebd1c..3a259a0 100644 --- a/examples/4_framework.py +++ b/examples/4_framework.py @@ -24,7 +24,9 @@ if "win32" in sys.platform: # Windows specific event-loop policy - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + asyncio.set_event_loop_policy( + asyncio.WindowsProactorEventLoopPolicy() # type: ignore + ) def force_async(fn): diff --git a/examples/4_secret_storage.py b/examples/4_secret_storage.py new file mode 100644 index 0000000..3aa8802 --- /dev/null +++ b/examples/4_secret_storage.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 + +################################### +# WHAT IS IN THIS EXAMPLE? +# +# Keybase has added an encrypted key-value store; see 4_simple_storage.py for +# more information. +# +# This example implements a simple bot to manage hackerspace tool rentals. It +# shows one way you can easily obfuscate entryKeys (which are by default +# not encrypted) so that no one but your team (not even Keybase) can know about +# the names of all the cool tools you have; you can do something similar to +# hide namespaces. Additionally this example handles concurrent writes using a cache +# to prevent one user from unintentionally clobbering another user's rental updates. +# ################################### + +import asyncio +import hmac +import json +import logging +import os +import secrets +import sys +from base64 import b64decode, b64encode +from enum import Enum +from typing import Any, Dict, List, Union + +from pykeybasebot import Bot, EventType, KVStoreClient +from pykeybasebot.errors import DeleteNonExistentError, RevisionError +from pykeybasebot.types import keybase1 + +logging.basicConfig(level=logging.DEBUG) + +if "win32" in sys.platform: + # Windows specific event-loop policy + asyncio.set_event_loop_policy( + asyncio.WindowsProactorEventLoopPolicy() # type: ignore + ) + + +def bytes_to_str(x): + return b64encode(x).decode("utf-8") + + +def str_to_bytes(x): + return b64decode(x.encode("utf-8")) + + +class RentalMsg(Enum): + ADD = "add" + REMOVE = "remove" + RESERVE = "reserve" + UNRESERVE = "unreserve" + LOOKUP = "lookup" + LIST = "list" + HELP = "help" + + +class SecretKeyKVStoreClient: + """ + Wrapper around KVStoreClient. + Hmacs entryKeys by a per-team, per-namespace random secret. + """ + + KEY_KEY = "_key" + + def __init__(self, kvstore: KVStoreClient): + self.kvstore = kvstore + + async def hmac_key(self, secret: bytes, key: str) -> str: + return hmac.new(secret, key.encode("utf-8")).hexdigest() + + async def put( + self, + secret: bytes, + team: str, + namespace: str, + entryKey: str, + entryValue: str, + revision: Union[int, None] = None, + ) -> keybase1.KVPutResult: + h = await self.hmac_key(secret, entryKey) + res = await self.kvstore.put(team, namespace, h, entryValue, revision) + res.entry_key = entryKey + return res + + async def delete( + self, + secret: bytes, + team: str, + namespace: str, + entryKey: str, + revision: Union[int, None] = None, + ) -> keybase1.KVDeleteEntryResult: + h = await self.hmac_key(secret, entryKey) + res = await self.kvstore.delete(team, namespace, h, revision) + res.entry_key = h + return res + + async def get( + self, secret: bytes, team: str, namespace: str, entryKey: str + ) -> keybase1.KVGetResult: + h = await self.hmac_key(secret, entryKey) + res = await self.kvstore.get(team, namespace, h) + res.entry_key = h + return res + + async def list_entrykeys( + self, secret: bytes, team: str, namespace: str + ) -> keybase1.KVListEntryResult: + res = await self.kvstore.list_entrykeys(team, namespace) + print(res.entry_keys) + if res.entry_keys: + for e in res.entry_keys: + if not e.entry_key.startswith("_"): + get_res = await self.kvstore.get(team, namespace, e.entry_key) + e.entry_key = json.loads(get_res.entry_value)[ + self.KEY_KEY + ] # modify + print("list_entrykeys: ", res) + return res + + +class RentalHandler: + """ + RentalHandler handles commands sent via chat to use the team key-value store. + + RentalHandler listens to chat messages of the form: + `!rental {reserve|unreserve} ` + `!rental {lookup|add|remove} ` + `!rental list` // lists all tools + + It expects properly formatted commands. + + It uses a cache to handle concurrent users. + It maintains a cache of the hmac secrets it has previously fetched (stored + with the special entryKey "_secret", and stores the hmac of the entryKey + instead of the plaintext entryKey. + """ + + MSG_PREFIX = "!rental" + NAMESPACE = "rental" + SECRET_NUM_BYTES = 32 + SECRET_KEY = "_secret" + + def __init__(self): + # cache = {tool: {"revision": int, "info": {} or None}} + self.cache: Dict[Any, Any] = {} + self.secrets: Dict[str, Dict[str, bytes]] = {} # {team: {namespace: secret}} + + async def load_secret(self, bot, team, namespace): + print("load box box: ", self.secrets) + if team not in self.secrets or namespace not in self.secrets[team]: + secret = secrets.token_bytes(self.SECRET_NUM_BYTES) + try: + # we don't expect self.SECRET_KEY's revision > 0 + res: keybase1.KVPutResult = await bot.kvstore.put( + team, namespace, self.SECRET_KEY, bytes_to_str(secret), revision=1 + ) + print("PUT RESULT: ", res) + except RevisionError: + res = await bot.kvstore.get(team, namespace, self.SECRET_KEY) + secret = str_to_bytes(res.entry_value) + print(">>>>gotten key: ", secret) + if team not in self.secrets: + self.secrets[team] = {} + self.secrets[team][namespace] = secret + print(">>>>>>>>>>>>>>>>>>") + print(self.secrets) + return self.secrets[team][namespace] + + def update_cache( + self, tool: str, reservations: Union[None, Dict[str, str]], revision: int + ): + self.cache[tool] = {"info": reservations, "revision": revision} + print("CACHE: ", self.cache) + + async def lookup( + self, bot, team, tool, secret: Union[bytes, None] + ) -> keybase1.KVGetResult: + print("TOOL : ", tool) + if secret is None: + secret = await self.load_secret(bot, team, self.NAMESPACE) + res = await SecretKeyKVStoreClient(bot.kvstore).get( + secret, team, self.NAMESPACE, tool + ) + info = json.loads(res.entry_value) if res.entry_value != "" else None + self.update_cache(tool, info, res.revision) + return res + + async def add( + self, bot, team, tool + ) -> Union[keybase1.KVPutResult, keybase1.KVGetResult]: + secret = await self.load_secret(bot, team, self.NAMESPACE) + info = {SecretKeyKVStoreClient.KEY_KEY: tool} + expected_revision = 1 + if tool in self.cache: + # if tool already exists, propagate existing info + if self.cache[tool]["info"] and type(self.cache[tool]["info"]) is dict: + info = self.cache[tool]["info"] + expected_revision = self.cache[tool]["revision"] + 1 + info_str = json.dumps(info) if type(info) is dict else "" + try: + res: keybase1.KVPutResult = await SecretKeyKVStoreClient(bot.kvstore).put( + secret, team, self.NAMESPACE, tool, info_str, expected_revision + ) + self.update_cache(tool, info, res.revision) + return res # successful put. return KVPUtResult + except RevisionError: + # refresh cached value + curr_info = await self.lookup(bot, team, tool, secret=secret) + return curr_info # failed put. return KVGetResult. + + async def remove( + self, bot, team, tool + ) -> Union[keybase1.KVDeleteEntryResult, keybase1.KVGetResult, None]: + secret = await self.load_secret(bot, team, self.NAMESPACE) + expected_revision = 1 + if tool in self.cache: + expected_revision = self.cache[tool]["revision"] + 1 + try: + res: keybase1.KVDeleteEntryResult = await SecretKeyKVStoreClient( + bot.kvstore + ).delete(secret, team, self.NAMESPACE, tool, expected_revision) + self.update_cache(tool, None, res.revision) + return res # successful delete. return KVDeleteEntryResult + except RevisionError: + # refresh cached value + curr_info = await self.lookup(bot, team, tool, secret=secret) + return curr_info # failed put. return KVGetResult. + except DeleteNonExistentError: + # refresh cached value + curr_info = await self.lookup(bot, team, tool, secret=secret) + return None # was already deleted. return None. + + async def update_reservation( + self, bot, team, user, tool, day, reserve=True + ) -> Union[keybase1.KVPutResult, keybase1.KVGetResult]: + # note: if you reserve or unreserve a not-added or deleted tool, it will add the tool + secret = await self.load_secret(bot, team, self.NAMESPACE) + info = {SecretKeyKVStoreClient.KEY_KEY: tool} + expected_revision = 1 + if tool in self.cache: + if self.cache[tool]["info"]: + info = self.cache[tool]["info"].copy() + expected_revision = self.cache[tool]["revision"] + 1 + if reserve: + info[day] = user + else: + # unreserve + info.pop(tool, None) + try: + res: keybase1.KVPutResult = await SecretKeyKVStoreClient(bot.kvstore).put( + secret, team, self.NAMESPACE, tool, json.dumps(info), expected_revision + ) + self.update_cache(tool, info, res.revision) + return res # successful put. return KVPUtResult + except RevisionError: + # refresh cached value + curr_info = await self.lookup(bot, team, tool, secret=None) + return curr_info # failed put. return KVGetResult. + + async def list_tools(self, bot, team) -> List[str]: + secret = await self.load_secret(bot, team, self.NAMESPACE) + res = await SecretKeyKVStoreClient(bot.kvstore).list_entrykeys( + secret, team, self.NAMESPACE + ) + keys = ( + [e.entry_key for e in res.entry_keys if not e.entry_key.startswith("_")] + if res.entry_keys + else [] + ) + return keys + + async def __call__(self, bot, event): + print("----") + print(event) + members_type = event.msg.channel.members_type + if not ( + event.type == EventType.CHAT + and (members_type == "team" or members_type == "impteamnative") + ): + return + + channel = event.msg.channel + user = event.msg.sender.username + + # support teams and implicit self teams + team = ( + channel.name if members_type == "team" else "{0},{0}".format(channel.name) + ) + + msg = event.msg.content.text.body.split(" ") + print(">>>msg: ", msg) + if msg[0] != self.MSG_PREFIX: + return + + if len(msg) == 2: + if msg[1] == RentalMsg.LIST.value: + # !rental list + send_msg = await self.list_tools(bot, team) + await bot.chat.send(channel, str(send_msg)) + return + if msg[1] == RentalMsg.HELP.value: + # !rental help + send_msg = "Available commands:\ + \n`!rental {reserve|unreserve} `\ + \n`!rental {lookup|add|remove} `\ + \n`!rental list` // lists all tools" + await bot.chat.send(channel, send_msg) + return + if len(msg) == 3: + (action, tool) = (msg[1], msg[2]) + if action == RentalMsg.LOOKUP.value: + # !rental lookup // lists info for all not-past days that tool is reserved + res = await self.lookup(bot, team, tool, None) + send_msg = ( + res.entry_value + if len(res.entry_value) > 0 + else "Entry does not exist." + ) + await bot.chat.send(channel, send_msg) + return + if action == RentalMsg.ADD.value: + res = await self.add(bot, team, tool) + if type(res) == keybase1.KVGetResult: + send_msg = "Failed to write. Cache updated; try again to confirm write? Most recently fetched entry: {}.".format( + res + ) + await bot.chat.send(channel, send_msg) + elif type(res) == keybase1.KVPutResult: + send_msg = "Successfully updated: {}".format(res) + await bot.chat.send(channel, send_msg) + return + if action == RentalMsg.REMOVE.value: + res = await self.remove(bot, team, tool) + if type(res) == keybase1.KVGetResult: + send_msg = "Failed to write. Cache updated; try again to confirm write? Most recently fetched entry: {}.".format( + res + ) + await bot.chat.send(channel, send_msg) + elif type(res) == keybase1.KVDeleteEntryResult: + send_msg = "Successfully updated: {}".format(res) + await bot.chat.send(channel, send_msg) + elif res is None: + send_msg = "Value already does not exist." + await bot.chat.send(channel, send_msg) + return + if len(msg) == 4 and ( + msg[1] == RentalMsg.RESERVE.value or msg[1] == RentalMsg.UNRESERVE.value + ): + (action, tool, day) = (msg[1], msg[2], msg[3]) + # !rental {reserve|unreserve} + res = await self.update_reservation( + bot, team, user, tool, day, reserve=(action == RentalMsg.RESERVE.value) + ) + if type(res) == keybase1.KVGetResult: + send_msg = "Failed to write. Cache updated; try again to confirm write? Most recently fetched entry: {}.".format( + res + ) + await bot.chat.send(channel, send_msg) + elif type(res) == keybase1.KVPutResult: + send_msg = "Successfully updated: {}".format(res) + await bot.chat.send(channel, send_msg) + return + await bot.chat.send(channel, "invalid !rental command") + return + + +username = "yourbot" + +bot = Bot( + username=username, paperkey=os.environ["KEYBASE_PAPERKEY"], handler=RentalHandler() +) + +asyncio.run(bot.start({})) diff --git a/examples/4_simple_storage.py b/examples/4_simple_storage.py index 83dc6e5..d01111f 100644 --- a/examples/4_simple_storage.py +++ b/examples/4_simple_storage.py @@ -37,7 +37,9 @@ if "win32" in sys.platform: # Windows specific event-loop policy - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + asyncio.set_event_loop_policy( + asyncio.WindowsProactorEventLoopPolicy() # type: ignore + ) class KVMsg(Enum): @@ -45,6 +47,7 @@ class KVMsg(Enum): GET = "get" DELETE = "delete" LIST = "list" + HELP = "help" class KVHandler: @@ -53,6 +56,8 @@ class KVHandler: KVHandler listens to chat messages of the form: `!storage put ()` + `!storage get ` + `!storage delete ()` `!storage {get|delete} ` `!storage list` `!storage list ` @@ -81,11 +86,22 @@ async def __call__(self, bot, event): if msg[0] != self.MSG_PREFIX: return - if len(msg) == 2 and msg[1] == KVMsg.LIST.value: - # !storage list - res = await bot.kvstore.list_namespaces(team) - await bot.chat.send(channel, str(res)) - return + if len(msg) == 2: + if msg[1] == KVMsg.LIST.value: + # !storage list + res = await bot.kvstore.list_namespaces(team) + await bot.chat.send(channel, str(res)) + return + if msg[1] == KVMsg.HELP.value: + # !storage help + send_msg = "Available commands:\ + \n`!storage put ()`\ + \n`!storage get `\ + \n`!storage delete ()`\ + \n`!storage list`\ + \n`!storage list `" + await bot.chat.send(channel, send_msg) + return if len(msg) == 3 and msg[1] == KVMsg.LIST.value: # !storage list namespace = msg[2] @@ -99,10 +115,15 @@ async def __call__(self, bot, event): res = await bot.kvstore.get(team, namespace, key) await bot.chat.send(channel, str(res)) return + if len(msg) == 4 or len(msg) == 5: + (action, namespace, key) = (msg[1], msg[2], msg[3]) + revision = int(msg[4]) if len(msg) == 5 else None if action == KVMsg.DELETE.value: - # !storage delete + # !storage delete () try: - res = await bot.kvstore.delete(team, namespace, key) + res = await bot.kvstore.delete( + team, namespace, key, revision=revision + ) await bot.chat.send(channel, str(res)) except Exception as e: await bot.chat.send(channel, str(e)) @@ -123,6 +144,8 @@ async def __call__(self, bot, event): except Exception as e: await bot.chat.send(channel, str(e)) return + await bot.chat.send(channel, "invalid !storage command") + return username = "yourbot" diff --git a/examples/4_totp_storage.py b/examples/4_totp_storage.py index da5ebfc..082161f 100644 --- a/examples/4_totp_storage.py +++ b/examples/4_totp_storage.py @@ -19,7 +19,7 @@ import sys from enum import Enum -import pyotp +import pyotp # type: ignore from pykeybasebot import Bot, EventType @@ -27,7 +27,9 @@ if "win32" in sys.platform: # Windows specific event-loop policy - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + asyncio.set_event_loop_policy( + asyncio.WindowsProactorEventLoopPolicy() # type: ignore + ) class TotpMsg(Enum): @@ -133,7 +135,6 @@ async def __call__(self, bot, event): \n`!totp {list|help}`" await bot.chat.send(channel, send_msg) return - if len(msg) == 3: action, issuer = msg[1], msg[2] if action == TotpMsg.NOW.value: @@ -155,7 +156,6 @@ async def __call__(self, bot, event): finally: await bot.chat.send(channel, send_msg) return - if len(msg) == 4: action, issuer, secret = msg[1], msg[2], msg[3] if action == TotpMsg.ADD.value: @@ -169,6 +169,7 @@ async def __call__(self, bot, event): finally: await bot.chat.send(channel, send_msg) return + await bot.chat.send(channel, "invalid !totp command") username = "yourbot" diff --git a/examples/5_customclient.py b/examples/5_customclient.py index 9396c69..5827a30 100644 --- a/examples/5_customclient.py +++ b/examples/5_customclient.py @@ -19,7 +19,9 @@ if "win32" in sys.platform: # Windows specific event-loop policy - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + asyncio.set_event_loop_policy( + asyncio.WindowsProactorEventLoopPolicy() # type: ignore + ) class CustomClient(ChatClient): diff --git a/poetry.lock b/poetry.lock index 5bff0f1..9d0a3b6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -56,6 +56,17 @@ optional = false python-versions = "*" version = "2019.9.11" +[[package]] +category = "dev" +description = "Foreign Function Interface for Python calling C code." +name = "cffi" +optional = false +python-versions = "*" +version = "1.13.1" + +[package.dependencies] +pycparser = "*" + [[package]] category = "dev" description = "Universal encoding detector for Python 2 and 3" @@ -258,6 +269,14 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "2.5.0" +[[package]] +category = "dev" +description = "C parser in Python" +name = "pycparser" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.19" + [[package]] category = "dev" description = "passive checker of Python programs" @@ -274,6 +293,18 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" version = "2.4.2" +[[package]] +category = "dev" +description = "Python binding to the Networking and Cryptography (NaCl) library" +name = "pynacl" +optional = false +python-versions = "*" +version = "1.3.0" + +[package.dependencies] +cffi = ">=1.4.1" +six = "*" + [[package]] category = "dev" description = "Python One Time Password Library" @@ -475,7 +506,7 @@ version = "0.6.0" more-itertools = "*" [metadata] -content-hash = "ccbcaf049809bc1e88e4c9e13712941be9b00dd1f14c9b7c1d8e59a2baa07f2f" +content-hash = "6fcdcbc809e43240cdcf32d22cce32ab0a87ce290f9fd0a6e7e2703eab4639ce" python-versions = "^3.7" [metadata.hashes] @@ -485,6 +516,7 @@ attrs = ["08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "f7 black = ["817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739", "e030a9a28f542debc08acceb273f228ac422798e5215ba2a791a6ddeaaca22a5"] bleach = ["213336e49e102af26d9cde77dd2d0397afabc5a6bf2fed985dc35b5d1e285a16", "3fdf7f77adcf649c9911387df51254b813185e32b2c6619f690b593a617e19fa"] certifi = ["e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", "fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"] +cffi = ["00d890313797d9fe4420506613384b43099ad7d2b905c0752dbcc3a6f14d80fa", "0cf9e550ac6c5e57b713437e2f4ac2d7fd0cd10336525a27224f5fc1ec2ee59a", "0ea23c9c0cdd6778146a50d867d6405693ac3b80a68829966c98dd5e1bbae400", "193697c2918ecdb3865acf6557cddf5076bb39f1f654975e087b67efdff83365", "1ae14b542bf3b35e5229439c35653d2ef7d8316c1fffb980f9b7647e544baa98", "1e389e069450609c6ffa37f21f40cce36f9be7643bbe5051ab1de99d5a779526", "263242b6ace7f9cd4ea401428d2d45066b49a700852334fd55311bde36dcda14", "33142ae9807665fa6511cfa9857132b2c3ee6ddffb012b3f0933fc11e1e830d5", "364f8404034ae1b232335d8c7f7b57deac566f148f7222cef78cf8ae28ef764e", "47368f69fe6529f8f49a5d146ddee713fc9057e31d61e8b6dc86a6a5e38cecc1", "4895640844f17bec32943995dc8c96989226974dfeb9dd121cc45d36e0d0c434", "558b3afef987cf4b17abd849e7bedf64ee12b28175d564d05b628a0f9355599b", "5ba86e1d80d458b338bda676fd9f9d68cb4e7a03819632969cf6d46b01a26730", "63424daa6955e6b4c70dc2755897f5be1d719eabe71b2625948b222775ed5c43", "6381a7d8b1ebd0bc27c3bc85bc1bfadbb6e6f756b4d4db0aa1425c3719ba26b4", "6381ab708158c4e1639da1f2a7679a9bbe3e5a776fc6d1fd808076f0e3145331", "6fd58366747debfa5e6163ada468a90788411f10c92597d3b0a912d07e580c36", "728ec653964655d65408949b07f9b2219df78badd601d6c49e28d604efe40599", "7cfcfda59ef1f95b9f729c56fe8a4041899f96b72685d36ef16a3440a0f85da8", "819f8d5197c2684524637f940445c06e003c4a541f9983fd30d6deaa2a5487d8", "825ecffd9574557590e3225560a8a9d751f6ffe4a49e3c40918c9969b93395fa", "8a2bcae2258d00fcfc96a9bde4a6177bc4274fe033f79311c5dd3d3148c26518", "9009e917d8f5ef780c2626e29b6bc126f4cb2a4d43ca67aa2b40f2a5d6385e78", "9c77564a51d4d914ed5af096cd9843d90c45b784b511723bd46a8a9d09cf16fc", "a19089fa74ed19c4fe96502a291cfdb89223a9705b1d73b3005df4256976142e", "a40ed527bffa2b7ebe07acc5a3f782da072e262ca994b4f2085100b5a444bbb2", "b8f09f21544b9899defb09afbdaeb200e6a87a2b8e604892940044cf94444644", "bb75ba21d5716abc41af16eac1145ab2e471deedde1f22c6f99bd9f995504df0", "e22a00c0c81ffcecaf07c2bfb3672fa372c50e2bd1024ffee0da191c1b27fc71", "e55b5a746fb77f10c83e8af081979351722f6ea48facea79d470b3731c7b2891", "ec2fa3ee81707a5232bf2dfbd6623fdb278e070d596effc7e2d788f2ada71a05", "fd82eb4694be712fcae03c717ca2e0fc720657ac226b80bbb597e971fc6928c2"] chardet = ["84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"] click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"] colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"] @@ -506,8 +538,10 @@ pkginfo = ["7424f2c8511c186cd5424bbf31045b77435b37a8d604990b79d4e70d741148bb", " pluggy = ["0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6", "fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34"] py = ["64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", "dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"] pycodestyle = ["95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"] +pycparser = ["a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3"] pyflakes = ["17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", "d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"] pygments = ["71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", "881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"] +pynacl = ["05c26f93964373fc0abe332676cb6735f0ecad27711035b9472751faa8521255", "0c6100edd16fefd1557da078c7a31e7b7d7a52ce39fdca2bec29d4f7b6e7600c", "0d0a8171a68edf51add1e73d2159c4bc19fc0718e79dec51166e940856c2f28e", "1c780712b206317a746ace34c209b8c29dbfd841dfbc02aa27f2084dd3db77ae", "2424c8b9f41aa65bbdbd7a64e73a7450ebb4aa9ddedc6a081e7afcc4c97f7621", "2d23c04e8d709444220557ae48ed01f3f1086439f12dbf11976e849a4926db56", "30f36a9c70450c7878053fa1344aca0145fd47d845270b43a7ee9192a051bf39", "37aa336a317209f1bb099ad177fef0da45be36a2aa664507c5d72015f956c310", "4943decfc5b905748f0756fdd99d4f9498d7064815c4cf3643820c9028b711d1", "53126cd91356342dcae7e209f840212a58dcf1177ad52c1d938d428eebc9fee5", "57ef38a65056e7800859e5ba9e6091053cd06e1038983016effaffe0efcd594a", "5bd61e9b44c543016ce1f6aef48606280e45f892a928ca7068fba30021e9b786", "6482d3017a0c0327a49dddc8bd1074cc730d45db2ccb09c3bac1f8f32d1eb61b", "7d3ce02c0784b7cbcc771a2da6ea51f87e8716004512493a2b69016326301c3b", "a14e499c0f5955dcc3991f785f3f8e2130ed504fa3a7f44009ff458ad6bdd17f", "a39f54ccbcd2757d1d63b0ec00a00980c0b382c62865b61a505163943624ab20", "aabb0c5232910a20eec8563503c153a8e78bbf5459490c49ab31f6adf3f3a415", "bd4ecb473a96ad0f90c20acba4f0bf0df91a4e03a1f4dd6a4bdc9ca75aa3a715", "bf459128feb543cfca16a95f8da31e2e65e4c5257d2f3dfa8c0c1031139c9c92", "e2da3c13307eac601f3de04887624939aca8ee3c9488a0bb0eca4fb9401fc6b1", "f67814c38162f4deb31f68d590771a29d5ae3b1bd64b75cf232308e5c74777e0"] pyotp = ["c88f37fd47541a580b744b42136f387cdad481b560ef410c0d85c957eb2a2bc0", "fc537e8acd985c5cbf51e11b7d53c42276fee017a73aec7c07380695671ca1a1"] pyparsing = ["6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80", "d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4"] pytest = ["27abc3fef618a01bebb1f0d6d303d2816a99aa87a5968ebc32fe971be91eb1e6", "58cee9e09242937e136dbb3dab466116ba20d6b7828c7620f23947f37eb4dae4"] diff --git a/pykeybasebot/errors.py b/pykeybasebot/errors.py new file mode 100644 index 0000000..ba16a00 --- /dev/null +++ b/pykeybasebot/errors.py @@ -0,0 +1,14 @@ +class Error(Exception): + def __init__(self, msg: str): + self.msg = msg + + def __str__(self): + return self.msg + + +class RevisionError(Error): + pass + + +class DeleteNonExistentError(Error): + pass diff --git a/pykeybasebot/kvstore_client.py b/pykeybasebot/kvstore_client.py index b85dfbc..6481a40 100644 --- a/pykeybasebot/kvstore_client.py +++ b/pykeybasebot/kvstore_client.py @@ -1,6 +1,7 @@ import json from typing import Any, Dict, Union +from .errors import DeleteNonExistentError, RevisionError from .types import keybase1 @@ -30,8 +31,14 @@ async def put( } if revision: args["params"]["options"]["revision"] = revision - res = await self.execute(args) - return keybase1.KVPutResult.from_dict(res) + try: + res = await self.execute(args) + return keybase1.KVPutResult.from_dict(res) + except Exception as e: + if e.args[0]["code"] == 2760: + raise RevisionError(e.args[0]["message"]) + else: + raise e async def delete( self, @@ -49,8 +56,16 @@ async def delete( } if revision: args["params"]["options"]["revision"] = revision - res = await self.execute(args) - return keybase1.KVDeleteEntryResult.from_dict(res) + try: + res = await self.execute(args) + return keybase1.KVDeleteEntryResult.from_dict(res) + except Exception as e: + if e.args[0]["code"] == 2762: + raise DeleteNonExistentError(e.args[0]["message"]) + elif e.args[0]["code"] == 2760: + raise RevisionError(e.args[0]["message"]) + else: + raise e async def get( self, team: str, namespace: str, entryKey: str diff --git a/pykeybasebot/types/chat1/__init__.py b/pykeybasebot/types/chat1/__init__.py index fd3ee23..225b637 100644 --- a/pykeybasebot/types/chat1/__init__.py +++ b/pykeybasebot/types/chat1/__init__.py @@ -2832,6 +2832,9 @@ class RemoteBotCommandsAdvertisementTLFID(DataClassJsonMixin): @dataclass class BotCommandConv(DataClassJsonMixin): uid: gregor1.UID = field(metadata=config(field_name="uid")) + untrusted_team_role: keybase1.TeamRole = field( + metadata=config(field_name="untrustedTeamRole") + ) conv_id: ConversationID = field(metadata=config(field_name="convID")) vers: CommandConvVers = field(metadata=config(field_name="vers")) mtime: gregor1.Time = field(metadata=config(field_name="mtime")) diff --git a/pykeybasebot/types/keybase1/__init__.py b/pykeybasebot/types/keybase1/__init__.py index 35921e1..1b9181d 100644 --- a/pykeybasebot/types/keybase1/__init__.py +++ b/pykeybasebot/types/keybase1/__init__.py @@ -1143,6 +1143,18 @@ class DbTypeStrings(Enum): DbValue = str +class OnLoginStartupStatus(Enum): + UNKNOWN = 0 + DISABLED = 1 + ENABLED = 2 + + +class OnLoginStartupStatusStrings(Enum): + UNKNOWN = "unknown" + DISABLED = "disabled" + ENABLED = "enabled" + + @dataclass class FirstStepResult(DataClassJsonMixin): val_plus_two: int = field(metadata=config(field_name="valPlusTwo")) @@ -2592,6 +2604,7 @@ class SubscriptionTopic(Enum): JOURNAL_STATUS = 1 ONLINE_STATUS = 2 DOWNLOAD_STATUS = 3 + FILES_TAB_BADGE = 4 class SubscriptionTopicStrings(Enum): @@ -2599,6 +2612,7 @@ class SubscriptionTopicStrings(Enum): JOURNAL_STATUS = "journal_status" ONLINE_STATUS = "online_status" DOWNLOAD_STATUS = "download_status" + FILES_TAB_BADGE = "files_tab_badge" class PathSubscriptionTopic(Enum): @@ -2611,6 +2625,20 @@ class PathSubscriptionTopicStrings(Enum): STAT = "stat" +class FilesTabBadge(Enum): + NONE = 0 + UPLOADING_STUCK = 1 + AWAITING_UPLOAD = 2 + UPLOADING = 3 + + +class FilesTabBadgeStrings(Enum): + NONE = "none" + UPLOADING_STUCK = "uploading_stuck" + AWAITING_UPLOAD = "awaiting_upload" + UPLOADING = "uploading" + + class GUIViewType(Enum): DEFAULT = 0 TEXT = 1 @@ -4225,6 +4253,7 @@ class TeambotKeyMetadata(DataClassJsonMixin): puk_generation: PerUserKeyGeneration = field( metadata=config(field_name="puk_generation") ) + application: TeamApplication = field(metadata=config(field_name="application")) @dataclass @@ -5026,13 +5055,13 @@ class BadgeState(DataClassJsonMixin): new_git_repo_global_unique_i_ds: Optional[Optional[List[str]]] = field( default=None, metadata=config(field_name="newGitRepoGlobalUniqueIDs") ) - new_team_names: Optional[Optional[List[str]]] = field( - default=None, metadata=config(field_name="newTeamNames") + new_teams: Optional[Optional[List[TeamID]]] = field( + default=None, metadata=config(field_name="newTeams") ) deleted_teams: Optional[Optional[List[DeletedTeamInfo]]] = field( default=None, metadata=config(field_name="deletedTeams") ) - new_team_access_requests: Optional[Optional[List[str]]] = field( + new_team_access_requests: Optional[Optional[List[TeamID]]] = field( default=None, metadata=config(field_name="newTeamAccessRequests") ) teams_with_reset_users: Optional[Optional[List[TeamMemberOutReset]]] = field( From 3d63ed23719f403e3cb2402124723262b69e165e Mon Sep 17 00:00:00 2001 From: M Mou Date: Sat, 2 Nov 2019 17:31:53 -0700 Subject: [PATCH 06/15] clean up --- examples/4_secret_storage.py | 49 ++++++++++++++++++------------------ examples/4_simple_storage.py | 4 +-- examples/4_totp_storage.py | 2 +- 3 files changed, 28 insertions(+), 27 deletions(-) diff --git a/examples/4_secret_storage.py b/examples/4_secret_storage.py index 3aa8802..c55628b 100644 --- a/examples/4_secret_storage.py +++ b/examples/4_secret_storage.py @@ -8,10 +8,11 @@ # # This example implements a simple bot to manage hackerspace tool rentals. It # shows one way you can easily obfuscate entryKeys (which are by default -# not encrypted) so that no one but your team (not even Keybase) can know about -# the names of all the cool tools you have; you can do something similar to -# hide namespaces. Additionally this example handles concurrent writes using a cache -# to prevent one user from unintentionally clobbering another user's rental updates. +# not encrypted) by storing their HMACs, so that no one but your team (not even +# Keybase) can know about the names of all the cool tools you have; you can do +# something similar to hide namespaces. Additionally this example handles +# concurrent writes using a cache to prevent one user from unintentionally +# clobbering another user's rental updates. # ################################### import asyncio @@ -59,7 +60,13 @@ class RentalMsg(Enum): class SecretKeyKVStoreClient: """ Wrapper around KVStoreClient. - Hmacs entryKeys by a per-team, per-namespace random secret. + HMACs entryKeys using the passed in secret, and stores the HMAC instead + of the plaintext entryKey. + + This approach to keeping the entryKeys private stores the plaintext entryKey + in the JSON entryValue under the key "_key". Listing all keys requires + getting each row in the (team, namespace). Also, this approach does not + hide memory access patterns. """ KEY_KEY = "_key" @@ -109,7 +116,6 @@ async def list_entrykeys( self, secret: bytes, team: str, namespace: str ) -> keybase1.KVListEntryResult: res = await self.kvstore.list_entrykeys(team, namespace) - print(res.entry_keys) if res.entry_keys: for e in res.entry_keys: if not e.entry_key.startswith("_"): @@ -117,7 +123,6 @@ async def list_entrykeys( e.entry_key = json.loads(get_res.entry_value)[ self.KEY_KEY ] # modify - print("list_entrykeys: ", res) return res @@ -132,10 +137,16 @@ class RentalHandler: It expects properly formatted commands. - It uses a cache to handle concurrent users. - It maintains a cache of the hmac secrets it has previously fetched (stored - with the special entryKey "_secret", and stores the hmac of the entryKey - instead of the plaintext entryKey. + RentalHandler uses a cache to keep track of the most recently fetched value and + revision for each key. To handle concurrent updates, it attempts to update + with the most recently fetched revision + 1; if it fails, it does a "get" + and updates the cache, and asks the user to retry if they still want to + do their update. + + RentalHandler also maintains a cache of the per-team per-namespace hmac secrets + it has previously fetched (stored with the special entryKey "_secret"), + which it uses to HMAC entryKeys. RentalHandler uses the SecretKeyKVStoreClient + to store entryKeys' HMACs instead of the plaintext entryKeys. """ MSG_PREFIX = "!rental" @@ -144,12 +155,11 @@ class RentalHandler: SECRET_KEY = "_secret" def __init__(self): - # cache = {tool: {"revision": int, "info": {} or None}} + # self.cache = {tool: {"revision": int, "info": {} or None}} self.cache: Dict[Any, Any] = {} self.secrets: Dict[str, Dict[str, bytes]] = {} # {team: {namespace: secret}} async def load_secret(self, bot, team, namespace): - print("load box box: ", self.secrets) if team not in self.secrets or namespace not in self.secrets[team]: secret = secrets.token_bytes(self.SECRET_NUM_BYTES) try: @@ -157,28 +167,22 @@ async def load_secret(self, bot, team, namespace): res: keybase1.KVPutResult = await bot.kvstore.put( team, namespace, self.SECRET_KEY, bytes_to_str(secret), revision=1 ) - print("PUT RESULT: ", res) except RevisionError: res = await bot.kvstore.get(team, namespace, self.SECRET_KEY) secret = str_to_bytes(res.entry_value) - print(">>>>gotten key: ", secret) if team not in self.secrets: self.secrets[team] = {} self.secrets[team][namespace] = secret - print(">>>>>>>>>>>>>>>>>>") - print(self.secrets) return self.secrets[team][namespace] def update_cache( self, tool: str, reservations: Union[None, Dict[str, str]], revision: int ): self.cache[tool] = {"info": reservations, "revision": revision} - print("CACHE: ", self.cache) async def lookup( self, bot, team, tool, secret: Union[bytes, None] ) -> keybase1.KVGetResult: - print("TOOL : ", tool) if secret is None: secret = await self.load_secret(bot, team, self.NAMESPACE) res = await SecretKeyKVStoreClient(bot.kvstore).get( @@ -196,7 +200,7 @@ async def add( expected_revision = 1 if tool in self.cache: # if tool already exists, propagate existing info - if self.cache[tool]["info"] and type(self.cache[tool]["info"]) is dict: + if self.cache[tool]["info"]: info = self.cache[tool]["info"] expected_revision = self.cache[tool]["revision"] + 1 info_str = json.dumps(info) if type(info) is dict else "" @@ -248,7 +252,7 @@ async def update_reservation( info[day] = user else: # unreserve - info.pop(tool, None) + info.pop(day, None) try: res: keybase1.KVPutResult = await SecretKeyKVStoreClient(bot.kvstore).put( secret, team, self.NAMESPACE, tool, json.dumps(info), expected_revision @@ -273,8 +277,6 @@ async def list_tools(self, bot, team) -> List[str]: return keys async def __call__(self, bot, event): - print("----") - print(event) members_type = event.msg.channel.members_type if not ( event.type == EventType.CHAT @@ -291,7 +293,6 @@ async def __call__(self, bot, event): ) msg = event.msg.content.text.body.split(" ") - print(">>>msg: ", msg) if msg[0] != self.MSG_PREFIX: return diff --git a/examples/4_simple_storage.py b/examples/4_simple_storage.py index d01111f..b437d23 100644 --- a/examples/4_simple_storage.py +++ b/examples/4_simple_storage.py @@ -11,8 +11,8 @@ # (2) persistent across logins # (3) fast and durable. # -# It supports putting, getting, listing and deleting. There is also a concurrency -# primitive, but check out the other example for that. A team has many +# It supports putting, getting, listing and deleting. There is also concurrency +# support, but check out 4_secret_storage.py for that. A team has many # namespaces, a namespace has many entryKeys, and an entryKey has one current # entryValue. Namespaces and entryKeys are in cleartext, and the Keybase client # service will encrypt and sign the entryValue on the way in (as well as diff --git a/examples/4_totp_storage.py b/examples/4_totp_storage.py index 082161f..4fd2d5a 100644 --- a/examples/4_totp_storage.py +++ b/examples/4_totp_storage.py @@ -7,7 +7,7 @@ # more information. # # This example shows how you can build a simple TOTP bot that makes use of -# the team encrypted key-value store, which we interact with using KVStoreClient. +# the team encrypted key-value store. # # This example does minimal error handling and is not concurrency safe. ################################### From a5356820ae272578cd0250702c479722d7b34456 Mon Sep 17 00:00:00 2001 From: M Mou Date: Wed, 6 Nov 2019 20:24:37 -0800 Subject: [PATCH 07/15] address pr coments --- Makefile | 5 +- examples/3_simple_storage.py | 181 ++++++++++ examples/4_simple_storage.py | 157 --------- examples/4_totp_storage.py | 128 ++++--- ..._secret_storage.py => 5_secret_storage.py} | 312 ++++++++++-------- pykeybasebot/errors.py | 21 +- pykeybasebot/kvstore_client.py | 14 +- 7 files changed, 460 insertions(+), 358 deletions(-) create mode 100644 examples/3_simple_storage.py delete mode 100644 examples/4_simple_storage.py rename examples/{4_secret_storage.py => 5_secret_storage.py} (58%) diff --git a/Makefile b/Makefile index d309327..5988db5 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,10 @@ test: poetry run black . --check types: - @mkdir -p pykeybasebot/types/{keybase1,gregor1,chat1,stellar1}/ + @mkdir -p pykeybasebot/types/keybase1 + @mkdir -p pykeybasebot/types/gregor1 + @mkdir -p pykeybasebot/types/chat1 + @mkdir -p pykeybasebot/types/stellar1 $(AVDLC) -b -l python -t -o pykeybasebot/types/keybase1 $(PROTOCOL_PATH)/avdl/keybase1/*.avdl $(AVDLC) -b -l python -t -o pykeybasebot/types/gregor1 $(PROTOCOL_PATH)/avdl/gregor1/*.avdl $(AVDLC) -b -l python -t -o pykeybasebot/types/chat1 $(PROTOCOL_PATH)/avdl/chat1/*.avdl diff --git a/examples/3_simple_storage.py b/examples/3_simple_storage.py new file mode 100644 index 0000000..190b050 --- /dev/null +++ b/examples/3_simple_storage.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 + +################################### +# WHAT IS IN THIS EXAMPLE? +# +# Keybase has added an encrypted key-value store intended to support +# security-conscious bot development with persistent state. It is a place to +# store small bits of data that are +# (1) encrypted for a team or user (via the user's implicit self-team: e.g. +# alice,alice), +# (2) persistent across logins +# (3) fast and durable. +# +# It supports putting, getting, listing, and deleting. There is also concurrency +# support, but check out 5_secret_storage.py for that. A team has many +# namespaces, a namespace has many entryKeys, and an entryKey has one current +# entryValue. Namespaces and entryKeys are in cleartext, and the Keybase client +# service will encrypt and sign the entryValue on the way in (as well as +# decrypt and verify on the way out) so keybase servers cannot see it or forge +# it. +# +# This example shows how you can use KVStoreClient to interact with the team +# encrypted key-value store. +################################### + +import asyncio +import logging +import os +import sys +from enum import Enum + +from pykeybasebot import Bot, EventType + +logging.basicConfig(level=logging.DEBUG) + +if "win32" in sys.platform: + # Windows specific event-loop policy + asyncio.set_event_loop_policy( + asyncio.WindowsProactorEventLoopPolicy() # type: ignore + ) + + +class KVMsg(Enum): + PUT = "put" + GET = "get" + DELETE = "delete" + LIST = "list" + HELP = "help" + + +class KVHandler: + """ + KVHandler handles commands sent via chat to use the team key-value store. + + KVHandler listens to chat messages of the form: + `!storage put ()` + `!storage get ` + `!storage delete ()` + `!storage list` // list namespaces + `!storage list ` // list entries in namespace + """ + + MSG_PREFIX = "!storage" + + async def __call__(self, bot, event): + members_type = event.msg.channel.members_type + if not ( + event.type == EventType.CHAT + and (members_type == "team" or members_type == "impteamnative") + ): + return + + channel = event.msg.channel + user = event.msg.sender.username + + # support teams and implicit self teams + team = ( + channel.name + if members_type == "team" or channel.name != user + else "{0},{0}".format(channel.name) + ) + + msg = "" + try: + msg = event.msg.content.text.body.strip().split(" ") + except AttributeError: + return + + if len(msg) < 2 or msg[0] != self.MSG_PREFIX: + return + + action = msg[1] + if action == KVMsg.HELP.value: + return await self.handle_help(bot, event, channel, team, msg, action) + if action == KVMsg.LIST.value: + return await self.handle_list(bot, event, channel, team, msg, action) + if action == KVMsg.GET.value: + return await self.handle_get(bot, event, channel, team, msg, action) + if action == KVMsg.PUT.value: + return await self.handle_put(bot, event, channel, team, msg, action) + if action == KVMsg.DELETE.value: + return await self.handle_delete(bot, event, channel, team, msg, action) + await bot.chat.send(channel, "invalid !storage command") + return + + async def handle_help(self, bot, event, channel, team, msg, action): + if len(msg) == 2: + # !storage help + send_msg = "Available commands:\ + \n`!storage put ()`\ + \n`!storage get `\ + \n`!storage delete ()`\ + \n`!storage list // list namespaces`\ + \n`!storage list // list entries in namespace`" + await bot.chat.send(channel, send_msg) + return + + async def handle_list(self, bot, event, channel, team, msg, action): + if len(msg) == 2: + # !storage list + res = await bot.kvstore.list_namespaces(team) + await bot.chat.send(channel, str(res)) + return + if len(msg) == 3: + # !storage list + namespace = msg[2] + res = await bot.kvstore.list_entrykeys(team, namespace) + await bot.chat.send(channel, str(res)) + return + + async def handle_get(self, bot, event, channel, team, msg, action): + if len(msg) == 4: + namespace, key = msg[2], msg[3] + # !storage get + res = await bot.kvstore.get(team, namespace, key) + await bot.chat.send(channel, str(res)) + return + + async def handle_put(self, bot, event, channel, team, msg, action): + if len(msg) == 5 or len(msg) == 6: + # !storage put () + namespace, key, value = msg[2], msg[3], msg[4] + try: + revision = int(msg[5]) if len(msg) == 6 else None + except ValueError as e: + await bot.chat.send(channel, str(e)) + try: + # note: if revision=None, the server does a get (to get + # the latest revision number) then a put (with revision + # number + 1); this operation is not atomic. + res = await bot.kvstore.put( + team, namespace, key, value, revision=revision + ) + await bot.chat.send(channel, str(res)) + except Exception as e: + await bot.chat.send(channel, str(e)) + return + + async def handle_delete(self, bot, event, channel, team, msg, action): + if len(msg) == 4 or len(msg) == 5: + # !storage delete () + namespace, key = msg[2], msg[3] + try: + revision = int(msg[4]) if len(msg) == 5 else None + except ValueError as e: + await bot.chat.send(channel, str(e)) + try: + res = await bot.kvstore.delete(team, namespace, key, revision=revision) + await bot.chat.send(channel, str(res)) + except Exception as e: + await bot.chat.send(channel, str(e)) + return + + +username = "yourbot" + +bot = Bot( + username=username, paperkey=os.environ["KEYBASE_PAPERKEY"], handler=KVHandler() +) + +asyncio.run(bot.start({})) diff --git a/examples/4_simple_storage.py b/examples/4_simple_storage.py deleted file mode 100644 index b437d23..0000000 --- a/examples/4_simple_storage.py +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env python3 - -################################### -# WHAT IS IN THIS EXAMPLE? -# -# Keybase has added an encrypted key-value store intended to support -# security-conscious bot development with persistent state. It is a place to -# store small bits of data that are -# (1) encrypted for a team or user (via the user's implicit self-team: e.g. -# alice,alice), -# (2) persistent across logins -# (3) fast and durable. -# -# It supports putting, getting, listing and deleting. There is also concurrency -# support, but check out 4_secret_storage.py for that. A team has many -# namespaces, a namespace has many entryKeys, and an entryKey has one current -# entryValue. Namespaces and entryKeys are in cleartext, and the Keybase client -# service will encrypt and sign the entryValue on the way in (as well as -# decrypt and verify on the way out) so keybase servers cannot see it or lie -# about it. -# -# This example shows how you can use KVStoreClient to interact with the team -# encrypted key-value store. -# -# This example propagates errors to the chat user and is not concurrency safe. -################################### - -import asyncio -import logging -import os -import sys -from enum import Enum - -from pykeybasebot import Bot, EventType - -logging.basicConfig(level=logging.DEBUG) - -if "win32" in sys.platform: - # Windows specific event-loop policy - asyncio.set_event_loop_policy( - asyncio.WindowsProactorEventLoopPolicy() # type: ignore - ) - - -class KVMsg(Enum): - PUT = "put" - GET = "get" - DELETE = "delete" - LIST = "list" - HELP = "help" - - -class KVHandler: - """ - KVHandler handles commands sent via chat to use the team key-value store. - - KVHandler listens to chat messages of the form: - `!storage put ()` - `!storage get ` - `!storage delete ()` - `!storage {get|delete} ` - `!storage list` - `!storage list ` - - It expects properly formatted commands. - """ - - MSG_PREFIX = "!storage" - - async def __call__(self, bot, event): - members_type = event.msg.channel.members_type - if not ( - event.type == EventType.CHAT - and (members_type == "team" or members_type == "impteamnative") - ): - return - - channel = event.msg.channel - - # support teams and implicit self teams - team = ( - channel.name if members_type == "team" else "{0},{0}".format(channel.name) - ) - - msg = event.msg.content.text.body.split(" ") - if msg[0] != self.MSG_PREFIX: - return - - if len(msg) == 2: - if msg[1] == KVMsg.LIST.value: - # !storage list - res = await bot.kvstore.list_namespaces(team) - await bot.chat.send(channel, str(res)) - return - if msg[1] == KVMsg.HELP.value: - # !storage help - send_msg = "Available commands:\ - \n`!storage put ()`\ - \n`!storage get `\ - \n`!storage delete ()`\ - \n`!storage list`\ - \n`!storage list `" - await bot.chat.send(channel, send_msg) - return - if len(msg) == 3 and msg[1] == KVMsg.LIST.value: - # !storage list - namespace = msg[2] - res = await bot.kvstore.list_entrykeys(team, namespace) - await bot.chat.send(channel, str(res)) - return - if len(msg) == 4: - (action, namespace, key) = (msg[1], msg[2], msg[3]) - if action == KVMsg.GET.value: - # !storage get - res = await bot.kvstore.get(team, namespace, key) - await bot.chat.send(channel, str(res)) - return - if len(msg) == 4 or len(msg) == 5: - (action, namespace, key) = (msg[1], msg[2], msg[3]) - revision = int(msg[4]) if len(msg) == 5 else None - if action == KVMsg.DELETE.value: - # !storage delete () - try: - res = await bot.kvstore.delete( - team, namespace, key, revision=revision - ) - await bot.chat.send(channel, str(res)) - except Exception as e: - await bot.chat.send(channel, str(e)) - return - if len(msg) == 5 or len(msg) == 6: - # !storage put () - (action, namespace, key, value) = (msg[1], msg[2], msg[3], msg[4]) - revision = int(msg[5]) if len(msg) == 6 else None - if action == KVMsg.PUT.value: - try: - # note: if revision=None, the server does a get (to get - # the latest revision number) then a put (with revision - # number + 1). this operation is not atomic. - res = await bot.kvstore.put( - team, namespace, key, value, revision=revision - ) - await bot.chat.send(channel, str(res)) - except Exception as e: - await bot.chat.send(channel, str(e)) - return - await bot.chat.send(channel, "invalid !storage command") - return - - -username = "yourbot" - -bot = Bot( - username=username, paperkey=os.environ["KEYBASE_PAPERKEY"], handler=KVHandler() -) - -asyncio.run(bot.start({})) diff --git a/examples/4_totp_storage.py b/examples/4_totp_storage.py index 4fd2d5a..39acf92 100644 --- a/examples/4_totp_storage.py +++ b/examples/4_totp_storage.py @@ -3,13 +3,11 @@ ################################### # WHAT IS IN THIS EXAMPLE? # -# Keybase has added an encrypted key-value store; see 4_simple_storage.py for +# Keybase has added an encrypted key-value store; see 3_simple_storage.py for # more information. # # This example shows how you can build a simple TOTP bot that makes use of # the team encrypted key-value store. -# -# This example does minimal error handling and is not concurrency safe. ################################### import asyncio @@ -110,72 +108,102 @@ async def __call__(self, bot, event): return channel = event.msg.channel + user = event.msg.sender.username # support teams and implicit self teams team = ( - channel.name if members_type == "team" else "{0},{0}".format(channel.name) + channel.name + if members_type == "team" or channel.name != user + else "{0},{0}".format(channel.name) ) - msg = event.msg.content.text.body.split(" ") - if len(msg) == 0 or msg[0] != self.MSG_PREFIX: + msg = "" + try: + msg = event.msg.content.text.body.strip().split(" ") + except AttributeError: + return + + if len(msg) < 2 or msg[0] != self.MSG_PREFIX: return + action = msg[1] + if action == TotpMsg.HELP.value: + return await self.handle_help(bot, event, channel, team, msg, action) + if action == TotpMsg.LIST.value: + return await self.handle_list(bot, event, channel, team, msg, action) + if action == TotpMsg.NOW.value: + return await self.handle_now(bot, event, channel, team, msg, action) + if action == TotpMsg.ADD.value: + return await self.handle_add(bot, event, channel, team, msg, action) + if action == TotpMsg.REMOVE.value: + return await self.handle_remove(bot, event, channel, team, msg, action) + await bot.chat.send(channel, "invalid !totp command") + return + + async def handle_help(self, bot, event, channel, team, msg, action): if len(msg) == 2: - action = msg[1] - if action == TotpMsg.LIST.value: - # chat: "!totp list" - ns = await self.list(bot, team) - await bot.chat.send(channel, str(ns)) - return - if action == TotpMsg.HELP.value: - # chat: "!totp help" - send_msg = "Available commands:\ + # chat: "!totp help" + send_msg = "Available commands:\ \n`!totp add `\ \n`!totp {now|remove} `\ \n`!totp {list|help}`" + await bot.chat.send(channel, send_msg) + return + + async def handle_list(self, bot, event, channel, team, msg, action): + if len(msg) == 2: + # chat: "!totp list" + ns = await self.list(bot, team) + await bot.chat.send(channel, str(ns)) + return + + async def handle_now(self, bot, event, channel, team, msg, action): + if len(msg) == 3: + # chat: "!totp now " + issuer = msg[2] + send_msg = "Error getting current TOTP for {}".format(issuer) + code = await self.now(bot, team, issuer) + if code: + send_msg = "Current TOTP for {}: {}".format(issuer, code) + await bot.chat.send(channel, send_msg) + return + + async def handle_add(self, bot, event, channel, team, msg, action): + if len(msg) == 4: + issuer, secret = msg[2], msg[3] + # chat: "!totp add " + send_msg = "Error adding TOTP for {0}".format(issuer) + try: + await self.add(bot, team, issuer, secret) + send_msg = "TOTP added for {}".format(issuer) + except Exception as e: + print(e) + finally: await bot.chat.send(channel, send_msg) - return + return + + async def handle_remove(self, bot, event, channel, team, msg, action): if len(msg) == 3: - action, issuer = msg[1], msg[2] - if action == TotpMsg.NOW.value: - # chat: "!totp now " - send_msg = "Error getting current TOTP for {}".format(issuer) - code = await self.now(bot, team, issuer) - if code: - send_msg = "Current TOTP for {}: {}".format(issuer, code) + issuer = msg[2] + # chat: "!totp remove " + send_msg = "No keys to remove for {}".format(issuer) + try: + await self.remove(bot, team, issuer) + send_msg = "Removed TOTP keys for {}".format(issuer) + except Exception as e: + print(e) + finally: await bot.chat.send(channel, send_msg) - return - if action == TotpMsg.REMOVE.value: - # chat: "!totp remove " - send_msg = "No keys to remove for {}".format(issuer) - try: - await self.remove(bot, team, issuer) - send_msg = "Removed TOTP keys for {}".format(issuer) - except Exception as e: - print(e) - finally: - await bot.chat.send(channel, send_msg) - return - if len(msg) == 4: - action, issuer, secret = msg[1], msg[2], msg[3] - if action == TotpMsg.ADD.value: - # chat: "!totp add " - send_msg = "Error adding TOTP for {0}".format(issuer) - try: - await self.add(bot, team, issuer, secret) - send_msg = "TOTP added for {}".format(issuer) - except Exception as e: - print(e) - finally: - await bot.chat.send(channel, send_msg) - return - await bot.chat.send(channel, "invalid !totp command") + return username = "yourbot" bot = Bot( - username=username, paperkey=os.environ["KEYBASE_PAPERKEY"], handler=TotpHandler() + username=username, + paperkey=os.environ["KEYBASE_PAPERKEY"], + handler=TotpHandler(), + keybase="/home/user/Documents/repos/go/src/github.com/keybase/client/go/keybase/bot/keybase", ) asyncio.run(bot.start({})) diff --git a/examples/4_secret_storage.py b/examples/5_secret_storage.py similarity index 58% rename from examples/4_secret_storage.py rename to examples/5_secret_storage.py index c55628b..2d3d788 100644 --- a/examples/4_secret_storage.py +++ b/examples/5_secret_storage.py @@ -7,8 +7,8 @@ # more information. # # This example implements a simple bot to manage hackerspace tool rentals. It -# shows one way you can easily obfuscate entryKeys (which are by default -# not encrypted) by storing their HMACs, so that no one but your team (not even +# shows one way you can obfuscate entryKeys (which are not encrypted) by +# storing their HMACs, so that no one but your team (not even # Keybase) can know about the names of all the cool tools you have; you can do # something similar to hide namespaces. Additionally this example handles # concurrent writes using a cache to prevent one user from unintentionally @@ -39,93 +39,130 @@ ) -def bytes_to_str(x): - return b64encode(x).decode("utf-8") - +class CachedBot(Bot): + """ + Custom bot maintains a cache of secrets for + SecretKeyKVStoreClients. + """ -def str_to_bytes(x): - return b64decode(x.encode("utf-8")) + @property + def secret_kvstore(self): + if not hasattr(self, "secret"): + # secrets = {team: {namespace: secret}} + self.secrets: Dict[str, Dict[str, bytes]] = {} + return SecretKeyKVStoreClient(self) -class RentalMsg(Enum): - ADD = "add" - REMOVE = "remove" - RESERVE = "reserve" - UNRESERVE = "unreserve" - LOOKUP = "lookup" - LIST = "list" - HELP = "help" +class SecretKeyKVStoreClient(KVStoreClient): + """ + A KVStoreClient that hides the entryKeys from Keybase servers. + It does so by HMACing entryKeys using a per-(team, namespace) secret, + and storing the HMAC instead of the plaintext entryKey. The secret + is not expected to change. -class SecretKeyKVStoreClient: - """ - Wrapper around KVStoreClient. - HMACs entryKeys using the passed in secret, and stores the HMAC instead - of the plaintext entryKey. - - This approach to keeping the entryKeys private stores the plaintext entryKey - in the JSON entryValue under the key "_key". Listing all keys requires - getting each row in the (team, namespace). Also, this approach does not - hide memory access patterns. + The plaintext entryKey is stored in the JSON entryValue under the key + "_key" to enable listing; listing all keys requires getting each row + in the (team, namespace). Also, this approach does not hide memory + access patterns. """ KEY_KEY = "_key" + SECRET_KEY = "_secret" + SECRET_NUM_BYTES = 32 + + def __init__(self, bot): + self.secrets = bot.secrets + super().__init__(bot) - def __init__(self, kvstore: KVStoreClient): - self.kvstore = kvstore + async def load_secret(self, team, namespace) -> bytes: + if team not in self.secrets or namespace not in self.secrets[team]: + secret = secrets.token_bytes(self.SECRET_NUM_BYTES) + try: + # we don't expect self.SECRET_KEY's revision > 0 + await super().put( + team, namespace, self.SECRET_KEY, bytes_to_str(secret), revision=1 + ) + except RevisionError: + res: keybase1.KVGetResult = await super().get( + team, namespace, self.SECRET_KEY + ) + secret = str_to_bytes(res.entry_value) + # update self.secrets (which also updates self.bot.secrets) + if team not in self.secrets: + self.secrets[team] = {} + self.secrets[team][namespace] = secret + return self.secrets[team][namespace] async def hmac_key(self, secret: bytes, key: str) -> str: return hmac.new(secret, key.encode("utf-8")).hexdigest() async def put( self, - secret: bytes, team: str, namespace: str, entryKey: str, entryValue: str, revision: Union[int, None] = None, ) -> keybase1.KVPutResult: + secret = await self.load_secret(team, namespace) h = await self.hmac_key(secret, entryKey) - res = await self.kvstore.put(team, namespace, h, entryValue, revision) + res = await super().put(team, namespace, h, entryValue, revision) res.entry_key = entryKey return res async def delete( self, - secret: bytes, team: str, namespace: str, entryKey: str, revision: Union[int, None] = None, ) -> keybase1.KVDeleteEntryResult: + secret = await self.load_secret(team, namespace) h = await self.hmac_key(secret, entryKey) - res = await self.kvstore.delete(team, namespace, h, revision) + res = await super().delete(team, namespace, h, revision) res.entry_key = h return res async def get( - self, secret: bytes, team: str, namespace: str, entryKey: str + self, team: str, namespace: str, entryKey: str ) -> keybase1.KVGetResult: + secret = await self.load_secret(team, namespace) h = await self.hmac_key(secret, entryKey) - res = await self.kvstore.get(team, namespace, h) + res = await super().get(team, namespace, h) res.entry_key = h return res async def list_entrykeys( - self, secret: bytes, team: str, namespace: str + self, team: str, namespace: str ) -> keybase1.KVListEntryResult: - res = await self.kvstore.list_entrykeys(team, namespace) + res = await super().list_entrykeys(team, namespace) if res.entry_keys: for e in res.entry_keys: if not e.entry_key.startswith("_"): - get_res = await self.kvstore.get(team, namespace, e.entry_key) - e.entry_key = json.loads(get_res.entry_value)[ - self.KEY_KEY - ] # modify + get_res = await super().get(team, namespace, e.entry_key) + e.entry_key = json.loads(get_res.entry_value)[self.KEY_KEY] return res +def bytes_to_str(x): + return b64encode(x).decode("utf-8") + + +def str_to_bytes(x): + return b64decode(x.encode("utf-8")) + + +class RentalMsg(Enum): + ADD = "add" + REMOVE = "remove" + RESERVE = "reserve" + UNRESERVE = "unreserve" + LOOKUP = "lookup" + LIST = "list" + HELP = "help" + + class RentalHandler: """ RentalHandler handles commands sent via chat to use the team key-value store. @@ -151,43 +188,18 @@ class RentalHandler: MSG_PREFIX = "!rental" NAMESPACE = "rental" - SECRET_NUM_BYTES = 32 - SECRET_KEY = "_secret" def __init__(self): # self.cache = {tool: {"revision": int, "info": {} or None}} self.cache: Dict[Any, Any] = {} - self.secrets: Dict[str, Dict[str, bytes]] = {} # {team: {namespace: secret}} - - async def load_secret(self, bot, team, namespace): - if team not in self.secrets or namespace not in self.secrets[team]: - secret = secrets.token_bytes(self.SECRET_NUM_BYTES) - try: - # we don't expect self.SECRET_KEY's revision > 0 - res: keybase1.KVPutResult = await bot.kvstore.put( - team, namespace, self.SECRET_KEY, bytes_to_str(secret), revision=1 - ) - except RevisionError: - res = await bot.kvstore.get(team, namespace, self.SECRET_KEY) - secret = str_to_bytes(res.entry_value) - if team not in self.secrets: - self.secrets[team] = {} - self.secrets[team][namespace] = secret - return self.secrets[team][namespace] def update_cache( self, tool: str, reservations: Union[None, Dict[str, str]], revision: int ): self.cache[tool] = {"info": reservations, "revision": revision} - async def lookup( - self, bot, team, tool, secret: Union[bytes, None] - ) -> keybase1.KVGetResult: - if secret is None: - secret = await self.load_secret(bot, team, self.NAMESPACE) - res = await SecretKeyKVStoreClient(bot.kvstore).get( - secret, team, self.NAMESPACE, tool - ) + async def lookup(self, bot, team, tool) -> keybase1.KVGetResult: + res = await bot.secret_kvstore.get(team, self.NAMESPACE, tool) info = json.loads(res.entry_value) if res.entry_value != "" else None self.update_cache(tool, info, res.revision) return res @@ -195,7 +207,6 @@ async def lookup( async def add( self, bot, team, tool ) -> Union[keybase1.KVPutResult, keybase1.KVGetResult]: - secret = await self.load_secret(bot, team, self.NAMESPACE) info = {SecretKeyKVStoreClient.KEY_KEY: tool} expected_revision = 1 if tool in self.cache: @@ -205,43 +216,41 @@ async def add( expected_revision = self.cache[tool]["revision"] + 1 info_str = json.dumps(info) if type(info) is dict else "" try: - res: keybase1.KVPutResult = await SecretKeyKVStoreClient(bot.kvstore).put( - secret, team, self.NAMESPACE, tool, info_str, expected_revision + res: keybase1.KVPutResult = await bot.secret_kvstore.put( + team, self.NAMESPACE, tool, info_str, expected_revision ) self.update_cache(tool, info, res.revision) return res # successful put. return KVPUtResult except RevisionError: # refresh cached value - curr_info = await self.lookup(bot, team, tool, secret=secret) + curr_info = await self.lookup(bot, team, tool) return curr_info # failed put. return KVGetResult. async def remove( self, bot, team, tool ) -> Union[keybase1.KVDeleteEntryResult, keybase1.KVGetResult, None]: - secret = await self.load_secret(bot, team, self.NAMESPACE) expected_revision = 1 if tool in self.cache: expected_revision = self.cache[tool]["revision"] + 1 try: - res: keybase1.KVDeleteEntryResult = await SecretKeyKVStoreClient( - bot.kvstore - ).delete(secret, team, self.NAMESPACE, tool, expected_revision) + res: keybase1.KVDeleteEntryResult = await bot.secret_kvstore.delete( + team, self.NAMESPACE, tool, expected_revision + ) self.update_cache(tool, None, res.revision) return res # successful delete. return KVDeleteEntryResult except RevisionError: # refresh cached value - curr_info = await self.lookup(bot, team, tool, secret=secret) + curr_info = await self.lookup(bot, team, tool) return curr_info # failed put. return KVGetResult. except DeleteNonExistentError: # refresh cached value - curr_info = await self.lookup(bot, team, tool, secret=secret) + curr_info = await self.lookup(bot, team, tool) return None # was already deleted. return None. async def update_reservation( self, bot, team, user, tool, day, reserve=True ) -> Union[keybase1.KVPutResult, keybase1.KVGetResult]: # note: if you reserve or unreserve a not-added or deleted tool, it will add the tool - secret = await self.load_secret(bot, team, self.NAMESPACE) info = {SecretKeyKVStoreClient.KEY_KEY: tool} expected_revision = 1 if tool in self.cache: @@ -254,21 +263,18 @@ async def update_reservation( # unreserve info.pop(day, None) try: - res: keybase1.KVPutResult = await SecretKeyKVStoreClient(bot.kvstore).put( - secret, team, self.NAMESPACE, tool, json.dumps(info), expected_revision + res: keybase1.KVPutResult = await bot.secret_kvstore.put( + team, self.NAMESPACE, tool, json.dumps(info), expected_revision ) self.update_cache(tool, info, res.revision) return res # successful put. return KVPUtResult except RevisionError: # refresh cached value - curr_info = await self.lookup(bot, team, tool, secret=None) + curr_info = await self.lookup(bot, team, tool) return curr_info # failed put. return KVGetResult. async def list_tools(self, bot, team) -> List[str]: - secret = await self.load_secret(bot, team, self.NAMESPACE) - res = await SecretKeyKVStoreClient(bot.kvstore).list_entrykeys( - secret, team, self.NAMESPACE - ) + res = await bot.secret_kvstore.list_entrykeys(team, self.NAMESPACE) keys = ( [e.entry_key for e in res.entry_keys if not e.entry_key.startswith("_")] if res.entry_keys @@ -289,69 +295,103 @@ async def __call__(self, bot, event): # support teams and implicit self teams team = ( - channel.name if members_type == "team" else "{0},{0}".format(channel.name) + channel.name + if members_type == "team" or channel.name != user + else "{0},{0}".format(channel.name) ) - msg = event.msg.content.text.body.split(" ") - if msg[0] != self.MSG_PREFIX: + msg = "" + try: + msg = event.msg.content.text.body.strip().split(" ") + except AttributeError: return + if len(msg) < 2 or msg[0] != self.MSG_PREFIX: + return + + action = msg[1] + if action == RentalMsg.HELP.value: + return await self.handle_help(bot, event, channel, team, msg, action) + if action == RentalMsg.LIST.value: + return await self.handle_list(bot, event, channel, team, msg, action) + if action == RentalMsg.LOOKUP.value: + return await self.handle_lookup(bot, event, channel, team, msg, action) + if action == RentalMsg.ADD.value: + return await self.handle_add(bot, event, channel, team, msg, action) + if action == RentalMsg.REMOVE.value: + return await self.handle_remove(bot, event, channel, team, msg, action) + if action == RentalMsg.RESERVE.value or action == RentalMsg.UNRESERVE.value: + return await self.handle_reserve( + bot, event, channel, team, msg, action, user + ) + await bot.chat.send(channel, "invalid !rental command") + return + + async def handle_help(self, bot, event, channel, team, msg, action): if len(msg) == 2: - if msg[1] == RentalMsg.LIST.value: - # !rental list - send_msg = await self.list_tools(bot, team) - await bot.chat.send(channel, str(send_msg)) - return - if msg[1] == RentalMsg.HELP.value: - # !rental help - send_msg = "Available commands:\ + # !rental help + send_msg = "Available commands:\ \n`!rental {reserve|unreserve} `\ \n`!rental {lookup|add|remove} `\ \n`!rental list` // lists all tools" + await bot.chat.send(channel, send_msg) + return + + async def handle_list(self, bot, event, channel, team, msg, action): + if len(msg) == 2: + # !rental list + send_msg = await self.list_tools(bot, team) + await bot.chat.send(channel, str(send_msg)) + return + + async def handle_lookup(self, bot, event, channel, team, msg, action): + if len(msg) == 3: + tool = msg[2] + # !rental lookup + res = await self.lookup(bot, team, tool) + send_msg = ( + res.entry_value if len(res.entry_value) > 0 else "Entry does not exist." + ) + await bot.chat.send(channel, send_msg) + return + + async def handle_add(self, bot, event, channel, team, msg, action): + if len(msg) == 3: + # !rental add + tool = msg[2] + res = await self.add(bot, team, tool) + if type(res) == keybase1.KVGetResult: + send_msg = "Failed to write. Cache updated; try again to confirm write? Most recently fetched entry: {}.".format( + res + ) + await bot.chat.send(channel, send_msg) + elif type(res) == keybase1.KVPutResult: + send_msg = "Successfully updated: {}".format(res) await bot.chat.send(channel, send_msg) - return + return + + async def handle_remove(self, bot, event, channel, team, msg, action): if len(msg) == 3: - (action, tool) = (msg[1], msg[2]) - if action == RentalMsg.LOOKUP.value: - # !rental lookup // lists info for all not-past days that tool is reserved - res = await self.lookup(bot, team, tool, None) - send_msg = ( - res.entry_value - if len(res.entry_value) > 0 - else "Entry does not exist." + # !rental remove + tool = msg[2] + res = await self.remove(bot, team, tool) + if type(res) == keybase1.KVGetResult: + send_msg = "Failed to write. Cache updated; try again to confirm write? Most recently fetched entry: {}.".format( + res ) await bot.chat.send(channel, send_msg) - return - if action == RentalMsg.ADD.value: - res = await self.add(bot, team, tool) - if type(res) == keybase1.KVGetResult: - send_msg = "Failed to write. Cache updated; try again to confirm write? Most recently fetched entry: {}.".format( - res - ) - await bot.chat.send(channel, send_msg) - elif type(res) == keybase1.KVPutResult: - send_msg = "Successfully updated: {}".format(res) - await bot.chat.send(channel, send_msg) - return - if action == RentalMsg.REMOVE.value: - res = await self.remove(bot, team, tool) - if type(res) == keybase1.KVGetResult: - send_msg = "Failed to write. Cache updated; try again to confirm write? Most recently fetched entry: {}.".format( - res - ) - await bot.chat.send(channel, send_msg) - elif type(res) == keybase1.KVDeleteEntryResult: - send_msg = "Successfully updated: {}".format(res) - await bot.chat.send(channel, send_msg) - elif res is None: - send_msg = "Value already does not exist." - await bot.chat.send(channel, send_msg) - return - if len(msg) == 4 and ( - msg[1] == RentalMsg.RESERVE.value or msg[1] == RentalMsg.UNRESERVE.value - ): - (action, tool, day) = (msg[1], msg[2], msg[3]) + elif type(res) == keybase1.KVDeleteEntryResult: + send_msg = "Successfully updated: {}".format(res) + await bot.chat.send(channel, send_msg) + elif res is None: + send_msg = "Value already does not exist." + await bot.chat.send(channel, send_msg) + return + + async def handle_reserve(self, bot, event, channel, team, msg, action, user): + if len(msg) == 4: # !rental {reserve|unreserve} + tool, day = msg[2], msg[3] res = await self.update_reservation( bot, team, user, tool, day, reserve=(action == RentalMsg.RESERVE.value) ) @@ -364,13 +404,11 @@ async def __call__(self, bot, event): send_msg = "Successfully updated: {}".format(res) await bot.chat.send(channel, send_msg) return - await bot.chat.send(channel, "invalid !rental command") - return username = "yourbot" -bot = Bot( +bot = CachedBot( username=username, paperkey=os.environ["KEYBASE_PAPERKEY"], handler=RentalHandler() ) diff --git a/pykeybasebot/errors.py b/pykeybasebot/errors.py index ba16a00..f727eaa 100644 --- a/pykeybasebot/errors.py +++ b/pykeybasebot/errors.py @@ -1,3 +1,6 @@ +from typing import Union + + class Error(Exception): def __init__(self, msg: str): self.msg = msg @@ -7,8 +10,22 @@ def __str__(self): class RevisionError(Error): - pass + CODE = 2760 class DeleteNonExistentError(Error): - pass + CODE = 2762 + + +def try_to_error(e: Exception) -> Union[Exception, Error]: + """ + Try to convert Exception presumably from kbsubmit() + (from CLI response json) into our custom Error types. + """ + if e.args[0]["code"] == RevisionError.CODE: + return RevisionError(e.args[0]["message"]) + elif e.args[0]["code"] == DeleteNonExistentError.CODE: + return DeleteNonExistentError(e.args[0]["message"]) + else: + # return original exception + return e diff --git a/pykeybasebot/kvstore_client.py b/pykeybasebot/kvstore_client.py index 6481a40..814a13d 100644 --- a/pykeybasebot/kvstore_client.py +++ b/pykeybasebot/kvstore_client.py @@ -1,7 +1,7 @@ import json from typing import Any, Dict, Union -from .errors import DeleteNonExistentError, RevisionError +from .errors import try_to_error from .types import keybase1 @@ -35,10 +35,7 @@ async def put( res = await self.execute(args) return keybase1.KVPutResult.from_dict(res) except Exception as e: - if e.args[0]["code"] == 2760: - raise RevisionError(e.args[0]["message"]) - else: - raise e + raise try_to_error(e) async def delete( self, @@ -60,12 +57,7 @@ async def delete( res = await self.execute(args) return keybase1.KVDeleteEntryResult.from_dict(res) except Exception as e: - if e.args[0]["code"] == 2762: - raise DeleteNonExistentError(e.args[0]["message"]) - elif e.args[0]["code"] == 2760: - raise RevisionError(e.args[0]["message"]) - else: - raise e + raise try_to_error(e) async def get( self, team: str, namespace: str, entryKey: str From 9183b58f84c5a460ebfa0defcd6d2222fc40b7ec Mon Sep 17 00:00:00 2001 From: M Mou Date: Thu, 7 Nov 2019 11:00:50 -0800 Subject: [PATCH 08/15] fixes --- examples/3_simple_storage.py | 13 ++++------- examples/4_totp_storage.py | 13 ++++------- examples/5_secret_storage.py | 41 +++++++++++++++++----------------- pykeybasebot/errors.py | 2 +- pykeybasebot/kvstore_client.py | 6 ++--- 5 files changed, 32 insertions(+), 43 deletions(-) diff --git a/examples/3_simple_storage.py b/examples/3_simple_storage.py index 190b050..b70ab77 100644 --- a/examples/3_simple_storage.py +++ b/examples/3_simple_storage.py @@ -64,21 +64,16 @@ class KVHandler: async def __call__(self, bot, event): members_type = event.msg.channel.members_type - if not ( - event.type == EventType.CHAT - and (members_type == "team" or members_type == "impteamnative") - ): + if not event.type == EventType.CHAT: return channel = event.msg.channel user = event.msg.sender.username # support teams and implicit self teams - team = ( - channel.name - if members_type == "team" or channel.name != user - else "{0},{0}".format(channel.name) - ) + team = channel.name + if members_type == "impteamnative" and channel.name == user: + team = "{0},{0}".format(channel.name) msg = "" try: diff --git a/examples/4_totp_storage.py b/examples/4_totp_storage.py index 39acf92..4522c00 100644 --- a/examples/4_totp_storage.py +++ b/examples/4_totp_storage.py @@ -101,21 +101,16 @@ async def now(self, bot, team, issuer): async def __call__(self, bot, event): members_type = event.msg.channel.members_type - if not ( - event.type == EventType.CHAT - and (members_type == "team" or members_type == "impteamnative") - ): + if not event.type == EventType.CHAT: return channel = event.msg.channel user = event.msg.sender.username # support teams and implicit self teams - team = ( - channel.name - if members_type == "team" or channel.name != user - else "{0},{0}".format(channel.name) - ) + team = channel.name + if members_type == "impteamnative" and channel.name == user: + team = "{0},{0}".format(channel.name) msg = "" try: diff --git a/examples/5_secret_storage.py b/examples/5_secret_storage.py index 2d3d788..aa0d62c 100644 --- a/examples/5_secret_storage.py +++ b/examples/5_secret_storage.py @@ -58,13 +58,19 @@ class SecretKeyKVStoreClient(KVStoreClient): A KVStoreClient that hides the entryKeys from Keybase servers. It does so by HMACing entryKeys using a per-(team, namespace) secret, - and storing the HMAC instead of the plaintext entryKey. The secret - is not expected to change. + and storing the HMAC instead of the plaintext entryKey. This approach + does not handle any secret rotation, and does not expect the secret to + change. The plaintext entryKey is stored in the JSON entryValue under the key "_key" to enable listing; listing all keys requires getting each row - in the (team, namespace). Also, this approach does not hide memory - access patterns. + in the (team, namespace). + + This approach does not hide memory access patterns. Also, Keybase + servers prevent a removed team member from continuing to access a team's + data, but if that were somehow bypassed*, a former team member who still + knows the HMAC secret can check for the presence of specific entryKeys + (*but you probably have bigger issues to deal with in that case...). """ KEY_KEY = "_key" @@ -94,8 +100,9 @@ async def load_secret(self, team, namespace) -> bytes: self.secrets[team][namespace] = secret return self.secrets[team][namespace] - async def hmac_key(self, secret: bytes, key: str) -> str: - return hmac.new(secret, key.encode("utf-8")).hexdigest() + async def hmac_key(self, team, namespace, entryKey) -> str: + secret = await self.load_secret(team, namespace) + return hmac.new(secret, entryKey.encode("utf-8")).hexdigest() async def put( self, @@ -105,8 +112,7 @@ async def put( entryValue: str, revision: Union[int, None] = None, ) -> keybase1.KVPutResult: - secret = await self.load_secret(team, namespace) - h = await self.hmac_key(secret, entryKey) + h = await self.hmac_key(team, namespace, entryKey) res = await super().put(team, namespace, h, entryValue, revision) res.entry_key = entryKey return res @@ -118,8 +124,7 @@ async def delete( entryKey: str, revision: Union[int, None] = None, ) -> keybase1.KVDeleteEntryResult: - secret = await self.load_secret(team, namespace) - h = await self.hmac_key(secret, entryKey) + h = await self.hmac_key(team, namespace, entryKey) res = await super().delete(team, namespace, h, revision) res.entry_key = h return res @@ -127,8 +132,7 @@ async def delete( async def get( self, team: str, namespace: str, entryKey: str ) -> keybase1.KVGetResult: - secret = await self.load_secret(team, namespace) - h = await self.hmac_key(secret, entryKey) + h = await self.hmac_key(team, namespace, entryKey) res = await super().get(team, namespace, h) res.entry_key = h return res @@ -284,21 +288,16 @@ async def list_tools(self, bot, team) -> List[str]: async def __call__(self, bot, event): members_type = event.msg.channel.members_type - if not ( - event.type == EventType.CHAT - and (members_type == "team" or members_type == "impteamnative") - ): + if not event.type == EventType.CHAT: return channel = event.msg.channel user = event.msg.sender.username # support teams and implicit self teams - team = ( - channel.name - if members_type == "team" or channel.name != user - else "{0},{0}".format(channel.name) - ) + team = channel.name + if members_type == "impteamnative" and channel.name == user: + team = "{0},{0}".format(channel.name) msg = "" try: diff --git a/pykeybasebot/errors.py b/pykeybasebot/errors.py index f727eaa..5528a25 100644 --- a/pykeybasebot/errors.py +++ b/pykeybasebot/errors.py @@ -17,7 +17,7 @@ class DeleteNonExistentError(Error): CODE = 2762 -def try_to_error(e: Exception) -> Union[Exception, Error]: +def disambiguate_error(e: Exception) -> Union[Exception, Error]: """ Try to convert Exception presumably from kbsubmit() (from CLI response json) into our custom Error types. diff --git a/pykeybasebot/kvstore_client.py b/pykeybasebot/kvstore_client.py index 814a13d..f49c7c4 100644 --- a/pykeybasebot/kvstore_client.py +++ b/pykeybasebot/kvstore_client.py @@ -1,7 +1,7 @@ import json from typing import Any, Dict, Union -from .errors import try_to_error +from .errors import disambiguate_error from .types import keybase1 @@ -35,7 +35,7 @@ async def put( res = await self.execute(args) return keybase1.KVPutResult.from_dict(res) except Exception as e: - raise try_to_error(e) + raise disambiguate_error(e) async def delete( self, @@ -57,7 +57,7 @@ async def delete( res = await self.execute(args) return keybase1.KVDeleteEntryResult.from_dict(res) except Exception as e: - raise try_to_error(e) + raise disambiguate_error(e) async def get( self, team: str, namespace: str, entryKey: str From db367bf2546af44e629c96d8efecffc9a420d08f Mon Sep 17 00:00:00 2001 From: M Mou Date: Thu, 7 Nov 2019 11:45:45 -0800 Subject: [PATCH 09/15] fix --- examples/3_simple_storage.py | 20 ++++++++++---------- examples/4_totp_storage.py | 20 ++++++++++---------- examples/5_secret_storage.py | 26 ++++++++++++-------------- 3 files changed, 32 insertions(+), 34 deletions(-) diff --git a/examples/3_simple_storage.py b/examples/3_simple_storage.py index b70ab77..da743b1 100644 --- a/examples/3_simple_storage.py +++ b/examples/3_simple_storage.py @@ -86,19 +86,19 @@ async def __call__(self, bot, event): action = msg[1] if action == KVMsg.HELP.value: - return await self.handle_help(bot, event, channel, team, msg, action) + return await self.handle_help(bot, channel, team, msg, action) if action == KVMsg.LIST.value: - return await self.handle_list(bot, event, channel, team, msg, action) + return await self.handle_list(bot, channel, team, msg, action) if action == KVMsg.GET.value: - return await self.handle_get(bot, event, channel, team, msg, action) + return await self.handle_get(bot, channel, team, msg, action) if action == KVMsg.PUT.value: - return await self.handle_put(bot, event, channel, team, msg, action) + return await self.handle_put(bot, channel, team, msg, action) if action == KVMsg.DELETE.value: - return await self.handle_delete(bot, event, channel, team, msg, action) + return await self.handle_delete(bot, channel, team, msg, action) await bot.chat.send(channel, "invalid !storage command") return - async def handle_help(self, bot, event, channel, team, msg, action): + async def handle_help(self, bot, channel, team, msg, action): if len(msg) == 2: # !storage help send_msg = "Available commands:\ @@ -110,7 +110,7 @@ async def handle_help(self, bot, event, channel, team, msg, action): await bot.chat.send(channel, send_msg) return - async def handle_list(self, bot, event, channel, team, msg, action): + async def handle_list(self, bot, channel, team, msg, action): if len(msg) == 2: # !storage list res = await bot.kvstore.list_namespaces(team) @@ -123,7 +123,7 @@ async def handle_list(self, bot, event, channel, team, msg, action): await bot.chat.send(channel, str(res)) return - async def handle_get(self, bot, event, channel, team, msg, action): + async def handle_get(self, bot, channel, team, msg, action): if len(msg) == 4: namespace, key = msg[2], msg[3] # !storage get @@ -131,7 +131,7 @@ async def handle_get(self, bot, event, channel, team, msg, action): await bot.chat.send(channel, str(res)) return - async def handle_put(self, bot, event, channel, team, msg, action): + async def handle_put(self, bot, channel, team, msg, action): if len(msg) == 5 or len(msg) == 6: # !storage put () namespace, key, value = msg[2], msg[3], msg[4] @@ -151,7 +151,7 @@ async def handle_put(self, bot, event, channel, team, msg, action): await bot.chat.send(channel, str(e)) return - async def handle_delete(self, bot, event, channel, team, msg, action): + async def handle_delete(self, bot, channel, team, msg, action): if len(msg) == 4 or len(msg) == 5: # !storage delete () namespace, key = msg[2], msg[3] diff --git a/examples/4_totp_storage.py b/examples/4_totp_storage.py index 4522c00..e49d169 100644 --- a/examples/4_totp_storage.py +++ b/examples/4_totp_storage.py @@ -123,19 +123,19 @@ async def __call__(self, bot, event): action = msg[1] if action == TotpMsg.HELP.value: - return await self.handle_help(bot, event, channel, team, msg, action) + return await self.handle_help(bot, channel, team, msg, action) if action == TotpMsg.LIST.value: - return await self.handle_list(bot, event, channel, team, msg, action) + return await self.handle_list(bot, channel, team, msg, action) if action == TotpMsg.NOW.value: - return await self.handle_now(bot, event, channel, team, msg, action) + return await self.handle_now(bot, channel, team, msg, action) if action == TotpMsg.ADD.value: - return await self.handle_add(bot, event, channel, team, msg, action) + return await self.handle_add(bot, channel, team, msg, action) if action == TotpMsg.REMOVE.value: - return await self.handle_remove(bot, event, channel, team, msg, action) + return await self.handle_remove(bot, channel, team, msg, action) await bot.chat.send(channel, "invalid !totp command") return - async def handle_help(self, bot, event, channel, team, msg, action): + async def handle_help(self, bot, channel, team, msg, action): if len(msg) == 2: # chat: "!totp help" send_msg = "Available commands:\ @@ -145,14 +145,14 @@ async def handle_help(self, bot, event, channel, team, msg, action): await bot.chat.send(channel, send_msg) return - async def handle_list(self, bot, event, channel, team, msg, action): + async def handle_list(self, bot, channel, team, msg, action): if len(msg) == 2: # chat: "!totp list" ns = await self.list(bot, team) await bot.chat.send(channel, str(ns)) return - async def handle_now(self, bot, event, channel, team, msg, action): + async def handle_now(self, bot, channel, team, msg, action): if len(msg) == 3: # chat: "!totp now " issuer = msg[2] @@ -163,7 +163,7 @@ async def handle_now(self, bot, event, channel, team, msg, action): await bot.chat.send(channel, send_msg) return - async def handle_add(self, bot, event, channel, team, msg, action): + async def handle_add(self, bot, channel, team, msg, action): if len(msg) == 4: issuer, secret = msg[2], msg[3] # chat: "!totp add " @@ -177,7 +177,7 @@ async def handle_add(self, bot, event, channel, team, msg, action): await bot.chat.send(channel, send_msg) return - async def handle_remove(self, bot, event, channel, team, msg, action): + async def handle_remove(self, bot, channel, team, msg, action): if len(msg) == 3: issuer = msg[2] # chat: "!totp remove " diff --git a/examples/5_secret_storage.py b/examples/5_secret_storage.py index aa0d62c..e0b9456 100644 --- a/examples/5_secret_storage.py +++ b/examples/5_secret_storage.py @@ -310,23 +310,21 @@ async def __call__(self, bot, event): action = msg[1] if action == RentalMsg.HELP.value: - return await self.handle_help(bot, event, channel, team, msg, action) + return await self.handle_help(bot, channel, team, msg, action) if action == RentalMsg.LIST.value: - return await self.handle_list(bot, event, channel, team, msg, action) + return await self.handle_list(bot, channel, team, msg, action) if action == RentalMsg.LOOKUP.value: - return await self.handle_lookup(bot, event, channel, team, msg, action) + return await self.handle_lookup(bot, channel, team, msg, action) if action == RentalMsg.ADD.value: - return await self.handle_add(bot, event, channel, team, msg, action) + return await self.handle_add(bot, channel, team, msg, action) if action == RentalMsg.REMOVE.value: - return await self.handle_remove(bot, event, channel, team, msg, action) + return await self.handle_remove(bot, channel, team, msg, action) if action == RentalMsg.RESERVE.value or action == RentalMsg.UNRESERVE.value: - return await self.handle_reserve( - bot, event, channel, team, msg, action, user - ) + return await self.handle_reserve(bot, channel, team, msg, action, user) await bot.chat.send(channel, "invalid !rental command") return - async def handle_help(self, bot, event, channel, team, msg, action): + async def handle_help(self, bot, channel, team, msg, action): if len(msg) == 2: # !rental help send_msg = "Available commands:\ @@ -336,14 +334,14 @@ async def handle_help(self, bot, event, channel, team, msg, action): await bot.chat.send(channel, send_msg) return - async def handle_list(self, bot, event, channel, team, msg, action): + async def handle_list(self, bot, channel, team, msg, action): if len(msg) == 2: # !rental list send_msg = await self.list_tools(bot, team) await bot.chat.send(channel, str(send_msg)) return - async def handle_lookup(self, bot, event, channel, team, msg, action): + async def handle_lookup(self, bot, channel, team, msg, action): if len(msg) == 3: tool = msg[2] # !rental lookup @@ -354,7 +352,7 @@ async def handle_lookup(self, bot, event, channel, team, msg, action): await bot.chat.send(channel, send_msg) return - async def handle_add(self, bot, event, channel, team, msg, action): + async def handle_add(self, bot, channel, team, msg, action): if len(msg) == 3: # !rental add tool = msg[2] @@ -369,7 +367,7 @@ async def handle_add(self, bot, event, channel, team, msg, action): await bot.chat.send(channel, send_msg) return - async def handle_remove(self, bot, event, channel, team, msg, action): + async def handle_remove(self, bot, channel, team, msg, action): if len(msg) == 3: # !rental remove tool = msg[2] @@ -387,7 +385,7 @@ async def handle_remove(self, bot, event, channel, team, msg, action): await bot.chat.send(channel, send_msg) return - async def handle_reserve(self, bot, event, channel, team, msg, action, user): + async def handle_reserve(self, bot, channel, team, msg, action, user): if len(msg) == 4: # !rental {reserve|unreserve} tool, day = msg[2], msg[3] From 6916555bb7a5015ba42e25ac9886b2327310cae9 Mon Sep 17 00:00:00 2001 From: M Mou Date: Fri, 8 Nov 2019 11:35:04 -0800 Subject: [PATCH 10/15] fix --- examples/4_totp_storage.py | 9 +++------ examples/5_secret_storage.py | 5 ++--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/examples/4_totp_storage.py b/examples/4_totp_storage.py index e49d169..d5e7b50 100644 --- a/examples/4_totp_storage.py +++ b/examples/4_totp_storage.py @@ -50,7 +50,7 @@ class TotpHandler: `!totp {list|help}` For each provisioned key, the handler stores in the namespace "totp" one - row, with the key "" and the json blob value "{"secret": <16 char base32 secret>}". This Keybase bot can be used in place of MFA apps like Google Authenticator and Authy @@ -166,7 +166,7 @@ async def handle_now(self, bot, channel, team, msg, action): async def handle_add(self, bot, channel, team, msg, action): if len(msg) == 4: issuer, secret = msg[2], msg[3] - # chat: "!totp add " + # chat: "!totp add " send_msg = "Error adding TOTP for {0}".format(issuer) try: await self.add(bot, team, issuer, secret) @@ -195,10 +195,7 @@ async def handle_remove(self, bot, channel, team, msg, action): username = "yourbot" bot = Bot( - username=username, - paperkey=os.environ["KEYBASE_PAPERKEY"], - handler=TotpHandler(), - keybase="/home/user/Documents/repos/go/src/github.com/keybase/client/go/keybase/bot/keybase", + username=username, paperkey=os.environ["KEYBASE_PAPERKEY"], handler=TotpHandler() ) asyncio.run(bot.start({})) diff --git a/examples/5_secret_storage.py b/examples/5_secret_storage.py index e0b9456..59dc295 100644 --- a/examples/5_secret_storage.py +++ b/examples/5_secret_storage.py @@ -62,9 +62,8 @@ class SecretKeyKVStoreClient(KVStoreClient): does not handle any secret rotation, and does not expect the secret to change. - The plaintext entryKey is stored in the JSON entryValue under the key - "_key" to enable listing; listing all keys requires getting each row - in the (team, namespace). + The plaintext entryKey is stored in it's corresponding JSON entryValue + under the key "_key" to enable listing. This approach does not hide memory access patterns. Also, Keybase servers prevent a removed team member from continuing to access a team's From 883d06747ceb27bd3784bcfea801322ed9bfe91a Mon Sep 17 00:00:00 2001 From: M Mou Date: Fri, 8 Nov 2019 16:37:35 -0800 Subject: [PATCH 11/15] did it --- examples/3_simple_storage.py | 21 +++++++++++---------- examples/4_totp_storage.py | 21 +++++++++++---------- examples/5_secret_storage.py | 19 +++++++++---------- 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/examples/3_simple_storage.py b/examples/3_simple_storage.py index da743b1..143ac6a 100644 --- a/examples/3_simple_storage.py +++ b/examples/3_simple_storage.py @@ -62,6 +62,15 @@ class KVHandler: MSG_PREFIX = "!storage" + def __init__(self): + self.handlers = { + KVMsg.HELP.value: self.handle_help, + KVMsg.LIST.value: self.handle_list, + KVMsg.GET.value: self.handle_get, + KVMsg.PUT.value: self.handle_put, + KVMsg.DELETE.value: self.handle_delete, + } + async def __call__(self, bot, event): members_type = event.msg.channel.members_type if not event.type == EventType.CHAT: @@ -85,16 +94,8 @@ async def __call__(self, bot, event): return action = msg[1] - if action == KVMsg.HELP.value: - return await self.handle_help(bot, channel, team, msg, action) - if action == KVMsg.LIST.value: - return await self.handle_list(bot, channel, team, msg, action) - if action == KVMsg.GET.value: - return await self.handle_get(bot, channel, team, msg, action) - if action == KVMsg.PUT.value: - return await self.handle_put(bot, channel, team, msg, action) - if action == KVMsg.DELETE.value: - return await self.handle_delete(bot, channel, team, msg, action) + if action in self.handlers: + return await self.handlers[action](bot, channel, team, msg, action) await bot.chat.send(channel, "invalid !storage command") return diff --git a/examples/4_totp_storage.py b/examples/4_totp_storage.py index d5e7b50..9813c7d 100644 --- a/examples/4_totp_storage.py +++ b/examples/4_totp_storage.py @@ -71,6 +71,15 @@ class TotpHandler: MSG_PREFIX = "!totp" NAMESPACE = "totp" + def __init__(self): + self.handlers = { + TotpMsg.HELP.value: self.handle_help, + TotpMsg.LIST.value: self.handle_list, + TotpMsg.ADD.value: self.handle_add, + TotpMsg.REMOVE.value: self.handle_remove, + TotpMsg.NOW.value: self.handle_now, + } + def to_json(self, secret): return {"secret": secret} @@ -122,16 +131,8 @@ async def __call__(self, bot, event): return action = msg[1] - if action == TotpMsg.HELP.value: - return await self.handle_help(bot, channel, team, msg, action) - if action == TotpMsg.LIST.value: - return await self.handle_list(bot, channel, team, msg, action) - if action == TotpMsg.NOW.value: - return await self.handle_now(bot, channel, team, msg, action) - if action == TotpMsg.ADD.value: - return await self.handle_add(bot, channel, team, msg, action) - if action == TotpMsg.REMOVE.value: - return await self.handle_remove(bot, channel, team, msg, action) + if action in self.handlers: + return await self.handlers[action](bot, channel, team, msg, action) await bot.chat.send(channel, "invalid !totp command") return diff --git a/examples/5_secret_storage.py b/examples/5_secret_storage.py index 59dc295..abbef54 100644 --- a/examples/5_secret_storage.py +++ b/examples/5_secret_storage.py @@ -195,6 +195,13 @@ class RentalHandler: def __init__(self): # self.cache = {tool: {"revision": int, "info": {} or None}} self.cache: Dict[Any, Any] = {} + self.handlers = { + RentalMsg.HELP.value: self.handle_help, + RentalMsg.LIST.value: self.handle_list, + RentalMsg.ADD.value: self.handle_add, + RentalMsg.REMOVE.value: self.handle_remove, + RentalMsg.LOOKUP.value: self.handle_lookup, + } def update_cache( self, tool: str, reservations: Union[None, Dict[str, str]], revision: int @@ -308,16 +315,8 @@ async def __call__(self, bot, event): return action = msg[1] - if action == RentalMsg.HELP.value: - return await self.handle_help(bot, channel, team, msg, action) - if action == RentalMsg.LIST.value: - return await self.handle_list(bot, channel, team, msg, action) - if action == RentalMsg.LOOKUP.value: - return await self.handle_lookup(bot, channel, team, msg, action) - if action == RentalMsg.ADD.value: - return await self.handle_add(bot, channel, team, msg, action) - if action == RentalMsg.REMOVE.value: - return await self.handle_remove(bot, channel, team, msg, action) + if action in self.handlers: + return await self.handlers[action](bot, channel, team, msg, action) if action == RentalMsg.RESERVE.value or action == RentalMsg.UNRESERVE.value: return await self.handle_reserve(bot, channel, team, msg, action, user) await bot.chat.send(channel, "invalid !rental command") From 9b67d0766e56879d1ca6b2c5d26a72ed2a1dfa0b Mon Sep 17 00:00:00 2001 From: M Mou Date: Mon, 11 Nov 2019 22:41:44 -0800 Subject: [PATCH 12/15] fixes --- examples/3_simple_storage.py | 213 +++++--------- examples/5_secret_storage.py | 517 +++++++++++++++++---------------- pykeybasebot/kvstore_client.py | 16 +- 3 files changed, 346 insertions(+), 400 deletions(-) diff --git a/examples/3_simple_storage.py b/examples/3_simple_storage.py index 143ac6a..c6b185d 100644 --- a/examples/3_simple_storage.py +++ b/examples/3_simple_storage.py @@ -25,11 +25,10 @@ import asyncio import logging -import os import sys -from enum import Enum -from pykeybasebot import Bot, EventType +from pykeybasebot import Bot +from pykeybasebot.errors import DeleteNonExistentError, RevisionError logging.basicConfig(level=logging.DEBUG) @@ -40,138 +39,76 @@ ) -class KVMsg(Enum): - PUT = "put" - GET = "get" - DELETE = "delete" - LIST = "list" - HELP = "help" - - -class KVHandler: - """ - KVHandler handles commands sent via chat to use the team key-value store. - - KVHandler listens to chat messages of the form: - `!storage put ()` - `!storage get ` - `!storage delete ()` - `!storage list` // list namespaces - `!storage list ` // list entries in namespace - """ - - MSG_PREFIX = "!storage" - - def __init__(self): - self.handlers = { - KVMsg.HELP.value: self.handle_help, - KVMsg.LIST.value: self.handle_list, - KVMsg.GET.value: self.handle_get, - KVMsg.PUT.value: self.handle_put, - KVMsg.DELETE.value: self.handle_delete, - } - - async def __call__(self, bot, event): - members_type = event.msg.channel.members_type - if not event.type == EventType.CHAT: - return - - channel = event.msg.channel - user = event.msg.sender.username - - # support teams and implicit self teams - team = channel.name - if members_type == "impteamnative" and channel.name == user: - team = "{0},{0}".format(channel.name) - - msg = "" - try: - msg = event.msg.content.text.body.strip().split(" ") - except AttributeError: - return - - if len(msg) < 2 or msg[0] != self.MSG_PREFIX: - return - - action = msg[1] - if action in self.handlers: - return await self.handlers[action](bot, channel, team, msg, action) - await bot.chat.send(channel, "invalid !storage command") - return - - async def handle_help(self, bot, channel, team, msg, action): - if len(msg) == 2: - # !storage help - send_msg = "Available commands:\ - \n`!storage put ()`\ - \n`!storage get `\ - \n`!storage delete ()`\ - \n`!storage list // list namespaces`\ - \n`!storage list // list entries in namespace`" - await bot.chat.send(channel, send_msg) - return - - async def handle_list(self, bot, channel, team, msg, action): - if len(msg) == 2: - # !storage list - res = await bot.kvstore.list_namespaces(team) - await bot.chat.send(channel, str(res)) - return - if len(msg) == 3: - # !storage list - namespace = msg[2] - res = await bot.kvstore.list_entrykeys(team, namespace) - await bot.chat.send(channel, str(res)) - return - - async def handle_get(self, bot, channel, team, msg, action): - if len(msg) == 4: - namespace, key = msg[2], msg[3] - # !storage get - res = await bot.kvstore.get(team, namespace, key) - await bot.chat.send(channel, str(res)) - return - - async def handle_put(self, bot, channel, team, msg, action): - if len(msg) == 5 or len(msg) == 6: - # !storage put () - namespace, key, value = msg[2], msg[3], msg[4] - try: - revision = int(msg[5]) if len(msg) == 6 else None - except ValueError as e: - await bot.chat.send(channel, str(e)) - try: - # note: if revision=None, the server does a get (to get - # the latest revision number) then a put (with revision - # number + 1); this operation is not atomic. - res = await bot.kvstore.put( - team, namespace, key, value, revision=revision - ) - await bot.chat.send(channel, str(res)) - except Exception as e: - await bot.chat.send(channel, str(e)) - return - - async def handle_delete(self, bot, channel, team, msg, action): - if len(msg) == 4 or len(msg) == 5: - # !storage delete () - namespace, key = msg[2], msg[3] - try: - revision = int(msg[4]) if len(msg) == 5 else None - except ValueError as e: - await bot.chat.send(channel, str(e)) - try: - res = await bot.kvstore.delete(team, namespace, key, revision=revision) - await bot.chat.send(channel, str(res)) - except Exception as e: - await bot.chat.send(channel, str(e)) - return - - -username = "yourbot" - -bot = Bot( - username=username, paperkey=os.environ["KEYBASE_PAPERKEY"], handler=KVHandler() -) - -asyncio.run(bot.start({})) +async def simple_user(): + team = "yourbookclub" + + def noop_handler(*args, **kwargs): + pass + + bot = Bot(handler=noop_handler()) + + namespace = "current-favorites" + key = "Sam" + + # put with default revision + # note: if revision=None, the server does a get (to get + # the latest revision number) then a put (with revision + # number + 1); this operation is not atomic. + value = "The Left Hand of Darkness" + res = await bot.kvstore.put(team, namespace, key, value, revision=None) + print("PUT: ", res) + rev = res.revision + + # fail put + try: + res = await bot.kvstore.put( + team, namespace, key, "Fahrenheit 451", revision=rev + ) + except RevisionError as e: + print("EXPECTING PUT FAIL: ", e) + + # list namespaces + res = await bot.kvstore.list_namespaces(team) + print("LIST NAMESPACES: ", res) + assert len(res.namespaces) > 0 + + # list entryKeys + res = await bot.kvstore.list_entrykeys(team, namespace) + print("LIST ENTRYKEYS: ", res) + assert len(res.entry_keys) > 0 + + # get + res = await bot.kvstore.get(team, namespace, key) + print("GET: ", res) + assert res.entry_value == value + + # fail delete + try: + res = await bot.kvstore.delete(team, namespace, key, revision=rev + 2) + except RevisionError as e: + print("EXPECTING DELETE FAIL: ", e) + + # delete + res = await bot.kvstore.delete(team, namespace, key, revision=rev + 1) + print("DELETE: ", res) + assert res.revision == rev + 1 + + # fail delete + try: + res = await bot.kvstore.delete(team, namespace, key, revision=rev + 2) + except DeleteNonExistentError as e: + print("EXPECTING DELETE FAIL: ", e) + + # get + res = await bot.kvstore.get(team, namespace, key) + print("GET: ", res) + assert res.entry_value == "" + + +async def main(): + print("Starting 3_simple_storage example...") + await simple_user() + print("...3_simple_storage example is complete.") + + +asyncio.run(main()) diff --git a/examples/5_secret_storage.py b/examples/5_secret_storage.py index abbef54..e871120 100644 --- a/examples/5_secret_storage.py +++ b/examples/5_secret_storage.py @@ -19,14 +19,12 @@ import hmac import json import logging -import os import secrets import sys from base64 import b64decode, b64encode -from enum import Enum from typing import Any, Dict, List, Union -from pykeybasebot import Bot, EventType, KVStoreClient +from pykeybasebot import Bot, KVStoreClient from pykeybasebot.errors import DeleteNonExistentError, RevisionError from pykeybasebot.types import keybase1 @@ -41,22 +39,117 @@ class CachedBot(Bot): """ - Custom bot maintains a cache of secrets for - SecretKeyKVStoreClients. + Custom bot that has some stateful clients. """ + def __init__(self, *args, **kwargs): + self.secret_kvstore_client = SecretKeyKVStoreClient(self) + self.cached_secret_kvstore_client = CachedKVStoreClient( + self, self.secret_kvstore_client + ) + super().__init__(*args, **kwargs) + @property - def secret_kvstore(self): - if not hasattr(self, "secret"): - # secrets = {team: {namespace: secret}} - self.secrets: Dict[str, Dict[str, bytes]] = {} - return SecretKeyKVStoreClient(self) + def cached_secret_kvstore(self): + return self.cached_secret_kvstore_client -class SecretKeyKVStoreClient(KVStoreClient): +class CachedKVStoreClient: """ - A KVStoreClient that hides the entryKeys from Keybase servers. + CachedKVStoreClient uses a cache to keep track of the most recently fetched value and + revision for each key. To handle concurrent updates, it attempts to update with + the most recently fetched revision + 1; if it fails, it does a "get" and updates + the cache, and returns that "get" result. + """ + + def __init__(self, bot, client): + # self.cache = {entryKey: {"revision": int, entryValue: {} or None}} + self.cache: Dict[Any, Any] = {} + self.kvstore = client + + # note that we expect entryValues to be JSON objects + def update_cache( + self, entry_key: str, entry_value: Union[None, Dict[str, str]], revision: int + ): + self.cache[entry_key] = {"info": entry_value, "revision": revision} + + # returns a copy of the cached value for a given entry_key + def get_cached(self, entry_key: str): + cached = self.cache[entry_key].copy() if entry_key in self.cache else None + if cached is not None: + cached["info"] = ( + cached["info"].copy() if cached["info"] is not None else None + ) + return cached + + async def put( + self, + team: str, + namespace: str, + entry_key: str, + entry_value: Dict[str, str], + revision: Union[int, None] = None, + ) -> Union[keybase1.KVPutResult, keybase1.KVGetResult]: + try: + res: keybase1.KVPutResult = await self.kvstore.put( + team, namespace, entry_key, entry_value, revision + ) + self.update_cache(entry_key, entry_value, res.revision) + return res # successful put. return KVPUtResult + except RevisionError: + # refresh cached value + curr_info = await self.get(team, namespace, entry_key) + return curr_info # failed put. return KVGetResult. + + async def delete( + self, + team: str, + namespace: str, + entry_key: str, + revision: Union[int, None] = None, + ) -> Union[keybase1.KVDeleteEntryResult, keybase1.KVGetResult, None]: + try: + res: keybase1.KVDeleteEntryResult = await self.kvstore.delete( + team, namespace, entry_key, revision + ) + self.update_cache(entry_key, None, res.revision) + return res # successful delete. return KVDeleteEntryResult + except RevisionError: + # refresh cached value + curr_info = await self.get(team, namespace, entry_key) + return curr_info # failed put. return KVGetResult. + except DeleteNonExistentError: + # refresh cached value + curr_info = await self.get(team, namespace, entry_key) + return None # was already deleted. return None. + return res + + async def get( + self, team: str, namespace: str, entry_key: str + ) -> keybase1.KVGetResult: + res = await self.kvstore.get(team, namespace, entry_key) + info = json.loads(res.entry_value) if res.entry_value != "" else None + self.update_cache(entry_key, info, res.revision) + return res + + async def list_entrykeys( + self, team: str, namespace: str + ) -> keybase1.KVListEntryResult: + res = await self.kvstore.list_entrykeys(team, namespace) + return res + + +def bytes_to_str(x): + return b64encode(x).decode("utf-8") + + +def str_to_bytes(x): + return b64decode(x.encode("utf-8")) + +class SecretKeyKVStoreClient: + """ + A SecretKeyKVStoreClient that hides the entryKeys from Keybase servers. It does so by HMACing entryKeys using a per-(team, namespace) secret, and storing the HMAC instead of the plaintext entryKey. This approach does not handle any secret rotation, and does not expect the secret to @@ -77,214 +170,143 @@ class SecretKeyKVStoreClient(KVStoreClient): SECRET_NUM_BYTES = 32 def __init__(self, bot): - self.secrets = bot.secrets - super().__init__(bot) + # secrets = {team: {namespace: secret}} + self.secrets: Dict[str, Dict[str, bytes]] = {} + self.kvstore: KVStoreClient = bot.kvstore async def load_secret(self, team, namespace) -> bytes: if team not in self.secrets or namespace not in self.secrets[team]: secret = secrets.token_bytes(self.SECRET_NUM_BYTES) try: # we don't expect self.SECRET_KEY's revision > 0 - await super().put( + await self.kvstore.put( team, namespace, self.SECRET_KEY, bytes_to_str(secret), revision=1 ) except RevisionError: - res: keybase1.KVGetResult = await super().get( + res: keybase1.KVGetResult = await self.kvstore.get( team, namespace, self.SECRET_KEY ) secret = str_to_bytes(res.entry_value) - # update self.secrets (which also updates self.bot.secrets) if team not in self.secrets: self.secrets[team] = {} self.secrets[team][namespace] = secret return self.secrets[team][namespace] - async def hmac_key(self, team, namespace, entryKey) -> str: + async def hmac_key(self, team, namespace, entry_key) -> str: secret = await self.load_secret(team, namespace) - return hmac.new(secret, entryKey.encode("utf-8")).hexdigest() + return hmac.new(secret, entry_key.encode("utf-8")).hexdigest() async def put( self, team: str, namespace: str, - entryKey: str, - entryValue: str, + entry_key: str, + entry_value: Dict[str, str], revision: Union[int, None] = None, ) -> keybase1.KVPutResult: - h = await self.hmac_key(team, namespace, entryKey) - res = await super().put(team, namespace, h, entryValue, revision) - res.entry_key = entryKey + entry_value[SecretKeyKVStoreClient.KEY_KEY] = entry_key + h = await self.hmac_key(team, namespace, entry_key) + res = await self.kvstore.put( + team, namespace, h, json.dumps(entry_value), revision + ) + res.entry_key = entry_key return res async def delete( self, team: str, namespace: str, - entryKey: str, + entry_key: str, revision: Union[int, None] = None, ) -> keybase1.KVDeleteEntryResult: - h = await self.hmac_key(team, namespace, entryKey) - res = await super().delete(team, namespace, h, revision) - res.entry_key = h + h = await self.hmac_key(team, namespace, entry_key) + res = await self.kvstore.delete(team, namespace, h, revision) + res.entry_key = entry_key return res async def get( - self, team: str, namespace: str, entryKey: str + self, team: str, namespace: str, entry_key: str ) -> keybase1.KVGetResult: - h = await self.hmac_key(team, namespace, entryKey) - res = await super().get(team, namespace, h) - res.entry_key = h + h = await self.hmac_key(team, namespace, entry_key) + res = await self.kvstore.get(team, namespace, h) + res.entry_key = entry_key return res async def list_entrykeys( self, team: str, namespace: str ) -> keybase1.KVListEntryResult: - res = await super().list_entrykeys(team, namespace) + res = await self.kvstore.list_entrykeys(team, namespace) if res.entry_keys: for e in res.entry_keys: if not e.entry_key.startswith("_"): - get_res = await super().get(team, namespace, e.entry_key) + get_res = await self.kvstore.get(team, namespace, e.entry_key) e.entry_key = json.loads(get_res.entry_value)[self.KEY_KEY] return res -def bytes_to_str(x): - return b64encode(x).decode("utf-8") - - -def str_to_bytes(x): - return b64decode(x.encode("utf-8")) - - -class RentalMsg(Enum): - ADD = "add" - REMOVE = "remove" - RESERVE = "reserve" - UNRESERVE = "unreserve" - LOOKUP = "lookup" - LIST = "list" - HELP = "help" - - -class RentalHandler: +class RentalClient: + """ + Wraps a CachedKVStoreClient to expose methods to handle tool rentals. """ - RentalHandler handles commands sent via chat to use the team key-value store. - - RentalHandler listens to chat messages of the form: - `!rental {reserve|unreserve} ` - `!rental {lookup|add|remove} ` - `!rental list` // lists all tools - - It expects properly formatted commands. - - RentalHandler uses a cache to keep track of the most recently fetched value and - revision for each key. To handle concurrent updates, it attempts to update - with the most recently fetched revision + 1; if it fails, it does a "get" - and updates the cache, and asks the user to retry if they still want to - do their update. - - RentalHandler also maintains a cache of the per-team per-namespace hmac secrets - it has previously fetched (stored with the special entryKey "_secret"), - which it uses to HMAC entryKeys. RentalHandler uses the SecretKeyKVStoreClient - to store entryKeys' HMACs instead of the plaintext entryKeys. - """ - MSG_PREFIX = "!rental" NAMESPACE = "rental" - def __init__(self): - # self.cache = {tool: {"revision": int, "info": {} or None}} - self.cache: Dict[Any, Any] = {} - self.handlers = { - RentalMsg.HELP.value: self.handle_help, - RentalMsg.LIST.value: self.handle_list, - RentalMsg.ADD.value: self.handle_add, - RentalMsg.REMOVE.value: self.handle_remove, - RentalMsg.LOOKUP.value: self.handle_lookup, - } - - def update_cache( - self, tool: str, reservations: Union[None, Dict[str, str]], revision: int - ): - self.cache[tool] = {"info": reservations, "revision": revision} + def __init__(self, kvstore: CachedKVStoreClient): + self.kvstore = kvstore - async def lookup(self, bot, team, tool) -> keybase1.KVGetResult: - res = await bot.secret_kvstore.get(team, self.NAMESPACE, tool) - info = json.loads(res.entry_value) if res.entry_value != "" else None - self.update_cache(tool, info, res.revision) + async def lookup(self, team, tool) -> keybase1.KVGetResult: + res = await self.kvstore.get(team, self.NAMESPACE, tool) return res async def add( - self, bot, team, tool + self, team, tool ) -> Union[keybase1.KVPutResult, keybase1.KVGetResult]: - info = {SecretKeyKVStoreClient.KEY_KEY: tool} + info: Dict[str, str] = {} expected_revision = 1 - if tool in self.cache: + cached = self.kvstore.get_cached(tool) + if cached is not None: # if tool already exists, propagate existing info - if self.cache[tool]["info"]: - info = self.cache[tool]["info"] - expected_revision = self.cache[tool]["revision"] + 1 - info_str = json.dumps(info) if type(info) is dict else "" - try: - res: keybase1.KVPutResult = await bot.secret_kvstore.put( - team, self.NAMESPACE, tool, info_str, expected_revision - ) - self.update_cache(tool, info, res.revision) - return res # successful put. return KVPUtResult - except RevisionError: - # refresh cached value - curr_info = await self.lookup(bot, team, tool) - return curr_info # failed put. return KVGetResult. + if cached["info"]: + info = cached["info"] if type(info) is dict else {} + expected_revision = cached["revision"] + 1 + res = await self.kvstore.put( + team, self.NAMESPACE, tool, info, expected_revision + ) + return res async def remove( - self, bot, team, tool + self, team, tool ) -> Union[keybase1.KVDeleteEntryResult, keybase1.KVGetResult, None]: expected_revision = 1 - if tool in self.cache: - expected_revision = self.cache[tool]["revision"] + 1 - try: - res: keybase1.KVDeleteEntryResult = await bot.secret_kvstore.delete( - team, self.NAMESPACE, tool, expected_revision - ) - self.update_cache(tool, None, res.revision) - return res # successful delete. return KVDeleteEntryResult - except RevisionError: - # refresh cached value - curr_info = await self.lookup(bot, team, tool) - return curr_info # failed put. return KVGetResult. - except DeleteNonExistentError: - # refresh cached value - curr_info = await self.lookup(bot, team, tool) - return None # was already deleted. return None. + cached = self.kvstore.get_cached(tool) + if cached is not None: + expected_revision = cached["revision"] + 1 + res = await self.kvstore.delete(team, self.NAMESPACE, tool, expected_revision) + return res async def update_reservation( - self, bot, team, user, tool, day, reserve=True + self, team, user, tool, day, reserve=True ) -> Union[keybase1.KVPutResult, keybase1.KVGetResult]: # note: if you reserve or unreserve a not-added or deleted tool, it will add the tool - info = {SecretKeyKVStoreClient.KEY_KEY: tool} + info: Dict[str, str] = {} expected_revision = 1 - if tool in self.cache: - if self.cache[tool]["info"]: - info = self.cache[tool]["info"].copy() - expected_revision = self.cache[tool]["revision"] + 1 + cached = self.kvstore.get_cached(tool) + if cached is not None: + if cached["info"]: + info = cached["info"] if type(info) is dict else {} + expected_revision = cached["revision"] + 1 if reserve: info[day] = user else: # unreserve info.pop(day, None) - try: - res: keybase1.KVPutResult = await bot.secret_kvstore.put( - team, self.NAMESPACE, tool, json.dumps(info), expected_revision - ) - self.update_cache(tool, info, res.revision) - return res # successful put. return KVPUtResult - except RevisionError: - # refresh cached value - curr_info = await self.lookup(bot, team, tool) - return curr_info # failed put. return KVGetResult. + res = await self.kvstore.put( + team, self.NAMESPACE, tool, info, expected_revision + ) + return res - async def list_tools(self, bot, team) -> List[str]: - res = await bot.secret_kvstore.list_entrykeys(team, self.NAMESPACE) + async def list_tools(self, team) -> List[str]: + res = await self.kvstore.list_entrykeys(team, self.NAMESPACE) keys = ( [e.entry_key for e in res.entry_keys if not e.entry_key.startswith("_")] if res.entry_keys @@ -292,119 +314,106 @@ async def list_tools(self, bot, team) -> List[str]: ) return keys - async def __call__(self, bot, event): - members_type = event.msg.channel.members_type - if not event.type == EventType.CHAT: - return - channel = event.msg.channel - user = event.msg.sender.username +async def rental_user(bot, rental, team, username): + res = await rental.list_tools(team) + print(res) - # support teams and implicit self teams - team = channel.name - if members_type == "impteamnative" and channel.name == user: - team = "{0},{0}".format(channel.name) + tool = "laz0rs" + res = await rental.lookup(team, tool) + print("LOOKUP: ", res) - msg = "" - try: - msg = event.msg.content.text.body.strip().split(" ") - except AttributeError: - return - - if len(msg) < 2 or msg[0] != self.MSG_PREFIX: - return - - action = msg[1] - if action in self.handlers: - return await self.handlers[action](bot, channel, team, msg, action) - if action == RentalMsg.RESERVE.value or action == RentalMsg.UNRESERVE.value: - return await self.handle_reserve(bot, channel, team, msg, action, user) - await bot.chat.send(channel, "invalid !rental command") - return - - async def handle_help(self, bot, channel, team, msg, action): - if len(msg) == 2: - # !rental help - send_msg = "Available commands:\ - \n`!rental {reserve|unreserve} `\ - \n`!rental {lookup|add|remove} `\ - \n`!rental list` // lists all tools" - await bot.chat.send(channel, send_msg) - return - - async def handle_list(self, bot, channel, team, msg, action): - if len(msg) == 2: - # !rental list - send_msg = await self.list_tools(bot, team) - await bot.chat.send(channel, str(send_msg)) - return - - async def handle_lookup(self, bot, channel, team, msg, action): - if len(msg) == 3: - tool = msg[2] - # !rental lookup - res = await self.lookup(bot, team, tool) - send_msg = ( - res.entry_value if len(res.entry_value) > 0 else "Entry does not exist." - ) - await bot.chat.send(channel, send_msg) - return - - async def handle_add(self, bot, channel, team, msg, action): - if len(msg) == 3: - # !rental add - tool = msg[2] - res = await self.add(bot, team, tool) - if type(res) == keybase1.KVGetResult: - send_msg = "Failed to write. Cache updated; try again to confirm write? Most recently fetched entry: {}.".format( - res - ) - await bot.chat.send(channel, send_msg) - elif type(res) == keybase1.KVPutResult: - send_msg = "Successfully updated: {}".format(res) - await bot.chat.send(channel, send_msg) - return - - async def handle_remove(self, bot, channel, team, msg, action): - if len(msg) == 3: - # !rental remove - tool = msg[2] - res = await self.remove(bot, team, tool) - if type(res) == keybase1.KVGetResult: - send_msg = "Failed to write. Cache updated; try again to confirm write? Most recently fetched entry: {}.".format( - res - ) - await bot.chat.send(channel, send_msg) - elif type(res) == keybase1.KVDeleteEntryResult: - send_msg = "Successfully updated: {}".format(res) - await bot.chat.send(channel, send_msg) - elif res is None: - send_msg = "Value already does not exist." - await bot.chat.send(channel, send_msg) - return - - async def handle_reserve(self, bot, channel, team, msg, action, user): - if len(msg) == 4: - # !rental {reserve|unreserve} - tool, day = msg[2], msg[3] - res = await self.update_reservation( - bot, team, user, tool, day, reserve=(action == RentalMsg.RESERVE.value) - ) - if type(res) == keybase1.KVGetResult: - send_msg = "Failed to write. Cache updated; try again to confirm write? Most recently fetched entry: {}.".format( - res - ) - await bot.chat.send(channel, send_msg) - elif type(res) == keybase1.KVPutResult: - send_msg = "Successfully updated: {}".format(res) - await bot.chat.send(channel, send_msg) - return + res = await rental.add(team, tool) + print("ADD: ", res) + + res = await rental.remove(team, tool) + print("REMOVE: ", res) + + res = await rental.add(team, tool) + print("ADD: ", res) + + res = await rental.update_reservation( + team, username, tool, "2044-03-12", reserve=True + ) + print("RESERVE: ", res) + + res = await rental.update_reservation( + team, username, tool, "2044-06-12", reserve=True + ) + print("RESERVE: ", res) + + res = await rental.lookup(team, tool) + print("LOOKUP: ", res) + + res = await rental.update_reservation( + team, username, tool, "2044-06-12", reserve=False + ) + print("UNRESERVE: ", res) + + res = await rental.lookup(team, tool) + print("LOOKUP: ", res) + + +async def concurrent_rental_users(bot, rental, team, username): + tool = "time travel machine" + + async def concurrent_rental_user(user_id: int): + date = "2044-10-0{}".format(user_id) + user = "user{}".format(user_id) + + i = 0 + while True: + # keep trying to reserve for user's unique date until successful + res = await rental.update_reservation(team, user, tool, date, reserve=True) + i += 1 + print("{}, attempt {}, TRY TO RESERVE: {}".format(user, i, res)) + if type(res) == keybase1.KVPutResult: + return + + async def pre(): + while True: + res = await rental.remove("yourhackerspace", tool) + if type(res) == keybase1.KVDeleteEntryResult: + print("REMOVE: {}".format(res)) + break + + async def post(): + # check that the tool has been reserved for all 5 unique dates + res = await rental.lookup("yourhackerspace", tool) + print("LOOKUP: {}".format(res)) + assert len(json.loads(res.entry_value)) == 6 # one key is "_key" + + await asyncio.wait_for(pre(), timeout=5.0) + # have 5 users concurrently try to reserve the same tool for 5 unique dates + await asyncio.gather( + concurrent_rental_user(1), + concurrent_rental_user(2), + concurrent_rental_user(3), + concurrent_rental_user(4), + concurrent_rental_user(5), + ) + await post() + + +async def main(): + print("Starting 5_secret_storage example...") + + team = "yourhackerspace" + username = "yourbot" + + def noop_handler(*args, **kwargs): + pass + + bot = CachedBot(handler=noop_handler(), keybase="/home/user/keybase") + rental = RentalClient(bot.cached_secret_kvstore) + + print("...one user does some basic rental actions...") + await rental_user(bot, rental, team, username) + print("...multiple users try to reserve...") + await concurrent_rental_users(bot, rental, team, username) -username = "yourbot" + print("...5_secret_storage example is complete.") -bot = CachedBot( - username=username, paperkey=os.environ["KEYBASE_PAPERKEY"], handler=RentalHandler() -) -asyncio.run(bot.start({})) +asyncio.run(main()) diff --git a/pykeybasebot/kvstore_client.py b/pykeybasebot/kvstore_client.py index f49c7c4..c7a7c92 100644 --- a/pykeybasebot/kvstore_client.py +++ b/pykeybasebot/kvstore_client.py @@ -13,8 +13,8 @@ async def put( self, team: str, namespace: str, - entryKey: str, - entryValue: str, + entry_key: str, + entry_value: str, revision: Union[int, None] = None, ) -> keybase1.KVPutResult: await self.bot.ensure_initialized() @@ -24,8 +24,8 @@ async def put( "options": { "team": team, "namespace": namespace, - "entryKey": entryKey, - "entryValue": entryValue, + "entryKey": entry_key, + "entryValue": entry_value, } }, } @@ -41,14 +41,14 @@ async def delete( self, team: str, namespace: str, - entryKey: str, + entry_key: str, revision: Union[int, None] = None, ) -> keybase1.KVDeleteEntryResult: await self.bot.ensure_initialized() args: Dict[str, Any] = { "method": "del", "params": { - "options": {"team": team, "namespace": namespace, "entryKey": entryKey} + "options": {"team": team, "namespace": namespace, "entryKey": entry_key} }, } if revision: @@ -60,7 +60,7 @@ async def delete( raise disambiguate_error(e) async def get( - self, team: str, namespace: str, entryKey: str + self, team: str, namespace: str, entry_key: str ) -> keybase1.KVGetResult: await self.bot.ensure_initialized() res = await self.execute( @@ -70,7 +70,7 @@ async def get( "options": { "team": team, "namespace": namespace, - "entryKey": entryKey, + "entryKey": entry_key, } }, } From 93ad5b40e3e9be54c0e0077d0ac0c2a7577e585e Mon Sep 17 00:00:00 2001 From: M Mou Date: Tue, 12 Nov 2019 12:29:37 -0800 Subject: [PATCH 13/15] some fixes --- examples/4_totp_storage.py | 2 +- examples/5_secret_storage.py | 39 ++++++++++++++++++------------------ 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/examples/4_totp_storage.py b/examples/4_totp_storage.py index 9813c7d..3928781 100644 --- a/examples/4_totp_storage.py +++ b/examples/4_totp_storage.py @@ -6,7 +6,7 @@ # Keybase has added an encrypted key-value store; see 3_simple_storage.py for # more information. # -# This example shows how you can build a simple TOTP bot that makes use of +# This example shows how you can build a simple TOTP chat bot that makes use of # the team encrypted key-value store. ################################### diff --git a/examples/5_secret_storage.py b/examples/5_secret_storage.py index e871120..d88f995 100644 --- a/examples/5_secret_storage.py +++ b/examples/5_secret_storage.py @@ -3,7 +3,7 @@ ################################### # WHAT IS IN THIS EXAMPLE? # -# Keybase has added an encrypted key-value store; see 4_simple_storage.py for +# Keybase has added an encrypted key-value store; see 3_simple_storage.py for # more information. # # This example implements a simple bot to manage hackerspace tool rentals. It @@ -11,7 +11,7 @@ # storing their HMACs, so that no one but your team (not even # Keybase) can know about the names of all the cool tools you have; you can do # something similar to hide namespaces. Additionally this example handles -# concurrent writes using a cache to prevent one user from unintentionally +# concurrent writes by using a cache to prevent one user from unintentionally # clobbering another user's rental updates. # ################################### @@ -37,21 +37,20 @@ ) -class CachedBot(Bot): +class CustomKVStoreBot(Bot): """ Custom bot that has some stateful clients. """ def __init__(self, *args, **kwargs): - self.secret_kvstore_client = SecretKeyKVStoreClient(self) - self.cached_secret_kvstore_client = CachedKVStoreClient( - self, self.secret_kvstore_client - ) + basic_client = KVStoreClient(self) + secret_kvstore_client = SecretKeyKVStoreClient(basic_client) + self._cached_secret_kvstore_client = CachedKVStoreClient(secret_kvstore_client) super().__init__(*args, **kwargs) @property - def cached_secret_kvstore(self): - return self.cached_secret_kvstore_client + def kvstore(self): + return self._cached_secret_kvstore_client class CachedKVStoreClient: @@ -62,7 +61,7 @@ class CachedKVStoreClient: the cache, and returns that "get" result. """ - def __init__(self, bot, client): + def __init__(self, client): # self.cache = {entryKey: {"revision": int, entryValue: {} or None}} self.cache: Dict[Any, Any] = {} self.kvstore = client @@ -95,7 +94,7 @@ async def put( team, namespace, entry_key, entry_value, revision ) self.update_cache(entry_key, entry_value, res.revision) - return res # successful put. return KVPUtResult + return res # successful put. return KVPutResult except RevisionError: # refresh cached value curr_info = await self.get(team, namespace, entry_key) @@ -169,10 +168,10 @@ class SecretKeyKVStoreClient: SECRET_KEY = "_secret" SECRET_NUM_BYTES = 32 - def __init__(self, bot): + def __init__(self, kvstore_client): # secrets = {team: {namespace: secret}} self.secrets: Dict[str, Dict[str, bytes]] = {} - self.kvstore: KVStoreClient = bot.kvstore + self.kvstore: KVStoreClient = kvstore_client async def load_secret(self, team, namespace) -> bytes: if team not in self.secrets or namespace not in self.secrets[team]: @@ -244,15 +243,15 @@ async def list_entrykeys( return res -class RentalClient: +class RentalBotClient: """ Wraps a CachedKVStoreClient to expose methods to handle tool rentals. """ NAMESPACE = "rental" - def __init__(self, kvstore: CachedKVStoreClient): - self.kvstore = kvstore + def __init__(self, bot): + self.kvstore = bot.kvstore async def lookup(self, team, tool) -> keybase1.KVGetResult: res = await self.kvstore.get(team, self.NAMESPACE, tool) @@ -372,14 +371,14 @@ async def concurrent_rental_user(user_id: int): async def pre(): while True: - res = await rental.remove("yourhackerspace", tool) + res = await rental.remove(team, tool) if type(res) == keybase1.KVDeleteEntryResult: print("REMOVE: {}".format(res)) break async def post(): # check that the tool has been reserved for all 5 unique dates - res = await rental.lookup("yourhackerspace", tool) + res = await rental.lookup(team, tool) print("LOOKUP: {}".format(res)) assert len(json.loads(res.entry_value)) == 6 # one key is "_key" @@ -404,8 +403,8 @@ async def main(): def noop_handler(*args, **kwargs): pass - bot = CachedBot(handler=noop_handler(), keybase="/home/user/keybase") - rental = RentalClient(bot.cached_secret_kvstore) + bot = CustomKVStoreBot(handler=noop_handler(), keybase="/home/user/keybase") + rental = RentalBotClient(bot) print("...one user does some basic rental actions...") await rental_user(bot, rental, team, username) From e93ee36bfcda504ee684248d9150a90b02a8fad6 Mon Sep 17 00:00:00 2001 From: M Mou Date: Tue, 12 Nov 2019 14:13:14 -0800 Subject: [PATCH 14/15] fix --- examples/5_secret_storage.py | 182 ++++++++++++++++++----------------- 1 file changed, 93 insertions(+), 89 deletions(-) diff --git a/examples/5_secret_storage.py b/examples/5_secret_storage.py index d88f995..4b12c19 100644 --- a/examples/5_secret_storage.py +++ b/examples/5_secret_storage.py @@ -10,9 +10,11 @@ # shows one way you can obfuscate entryKeys (which are not encrypted) by # storing their HMACs, so that no one but your team (not even # Keybase) can know about the names of all the cool tools you have; you can do -# something similar to hide namespaces. Additionally this example handles -# concurrent writes by using a cache to prevent one user from unintentionally -# clobbering another user's rental updates. +# something similar to hide namespaces. +# +# Additionally this example handles concurrent writes by using explicit revision +# numbers to prevent one user from unintentionally clobbering another user's +# rental updates. # ################################### import asyncio @@ -22,7 +24,7 @@ import secrets import sys from base64 import b64decode, b64encode -from typing import Any, Dict, List, Union +from typing import Dict, List, Union from pykeybasebot import Bot, KVStoreClient from pykeybasebot.errors import DeleteNonExistentError, RevisionError @@ -44,43 +46,24 @@ class CustomKVStoreBot(Bot): def __init__(self, *args, **kwargs): basic_client = KVStoreClient(self) - secret_kvstore_client = SecretKeyKVStoreClient(basic_client) - self._cached_secret_kvstore_client = CachedKVStoreClient(secret_kvstore_client) + secret_kvstore_client = SecretKeyKVStoreClient(basic_client) # is stateful + self._trying_secret_kvstore_client = TryingKVStoreClient(secret_kvstore_client) super().__init__(*args, **kwargs) @property def kvstore(self): - return self._cached_secret_kvstore_client + return self._trying_secret_kvstore_client -class CachedKVStoreClient: +class TryingKVStoreClient: """ - CachedKVStoreClient uses a cache to keep track of the most recently fetched value and - revision for each key. To handle concurrent updates, it attempts to update with - the most recently fetched revision + 1; if it fails, it does a "get" and updates - the cache, and returns that "get" result. + TryingKVStoreClient tries kvstore write actions with explicit revision numbers. + If it fails to write, it does a "get" and returns the get result. """ def __init__(self, client): - # self.cache = {entryKey: {"revision": int, entryValue: {} or None}} - self.cache: Dict[Any, Any] = {} self.kvstore = client - # note that we expect entryValues to be JSON objects - def update_cache( - self, entry_key: str, entry_value: Union[None, Dict[str, str]], revision: int - ): - self.cache[entry_key] = {"info": entry_value, "revision": revision} - - # returns a copy of the cached value for a given entry_key - def get_cached(self, entry_key: str): - cached = self.cache[entry_key].copy() if entry_key in self.cache else None - if cached is not None: - cached["info"] = ( - cached["info"].copy() if cached["info"] is not None else None - ) - return cached - async def put( self, team: str, @@ -93,12 +76,10 @@ async def put( res: keybase1.KVPutResult = await self.kvstore.put( team, namespace, entry_key, entry_value, revision ) - self.update_cache(entry_key, entry_value, res.revision) return res # successful put. return KVPutResult except RevisionError: - # refresh cached value - curr_info = await self.get(team, namespace, entry_key) - return curr_info # failed put. return KVGetResult. + get = await self.get(team, namespace, entry_key) + return get # failed put. return KVGetResult. async def delete( self, @@ -106,29 +87,21 @@ async def delete( namespace: str, entry_key: str, revision: Union[int, None] = None, - ) -> Union[keybase1.KVDeleteEntryResult, keybase1.KVGetResult, None]: + ) -> Union[keybase1.KVDeleteEntryResult, keybase1.KVGetResult]: try: res: keybase1.KVDeleteEntryResult = await self.kvstore.delete( team, namespace, entry_key, revision ) - self.update_cache(entry_key, None, res.revision) return res # successful delete. return KVDeleteEntryResult - except RevisionError: - # refresh cached value - curr_info = await self.get(team, namespace, entry_key) - return curr_info # failed put. return KVGetResult. - except DeleteNonExistentError: - # refresh cached value - curr_info = await self.get(team, namespace, entry_key) - return None # was already deleted. return None. + except (RevisionError, DeleteNonExistentError): + get = await self.get(team, namespace, entry_key) + return get # failed put. return KVGetResult. return res async def get( self, team: str, namespace: str, entry_key: str ) -> keybase1.KVGetResult: res = await self.kvstore.get(team, namespace, entry_key) - info = json.loads(res.entry_value) if res.entry_value != "" else None - self.update_cache(entry_key, info, res.revision) return res async def list_entrykeys( @@ -245,7 +218,7 @@ async def list_entrykeys( class RentalBotClient: """ - Wraps a CachedKVStoreClient to expose methods to handle tool rentals. + Wraps a KVStoreClient to expose methods to handle tool rentals. """ NAMESPACE = "rental" @@ -260,14 +233,11 @@ async def lookup(self, team, tool) -> keybase1.KVGetResult: async def add( self, team, tool ) -> Union[keybase1.KVPutResult, keybase1.KVGetResult]: - info: Dict[str, str] = {} - expected_revision = 1 - cached = self.kvstore.get_cached(tool) - if cached is not None: - # if tool already exists, propagate existing info - if cached["info"]: - info = cached["info"] if type(info) is dict else {} - expected_revision = cached["revision"] + 1 + res = await self.lookup(team, tool) + info = ( + json.loads(res.entry_value) if res.entry_value != "" else {} + ) # if tool already exists, propagate existing info + expected_revision = res.revision + 1 res = await self.kvstore.put( team, self.NAMESPACE, tool, info, expected_revision ) @@ -277,28 +247,44 @@ async def remove( self, team, tool ) -> Union[keybase1.KVDeleteEntryResult, keybase1.KVGetResult, None]: expected_revision = 1 - cached = self.kvstore.get_cached(tool) - if cached is not None: - expected_revision = cached["revision"] + 1 + res = await self.lookup(team, tool) + expected_revision = res.revision + 1 res = await self.kvstore.delete(team, self.NAMESPACE, tool, expected_revision) return res - async def update_reservation( - self, team, user, tool, day, reserve=True + async def reserve( + self, team, user, tool, day ) -> Union[keybase1.KVPutResult, keybase1.KVGetResult]: - # note: if you reserve or unreserve a not-added or deleted tool, it will add the tool - info: Dict[str, str] = {} - expected_revision = 1 - cached = self.kvstore.get_cached(tool) - if cached is not None: - if cached["info"]: - info = cached["info"] if type(info) is dict else {} - expected_revision = cached["revision"] + 1 - if reserve: - info[day] = user + """ + reserve a tool for a given day if that day is already not reserved. + note: if you reserve a not-added or deleted tool, it will add the tool + """ + res = await self.lookup(team, tool) + info = json.loads(res.entry_value) if res.entry_value != "" else {} + if day in info: + return res # failed to put because day is already reserved. else: - # unreserve - info.pop(day, None) + info[day] = user + expected_revision = res.revision + 1 + res = await self.kvstore.put( + team, self.NAMESPACE, tool, info, expected_revision + ) + return res + + async def unreserve( + self, team, user, tool, day + ) -> Union[keybase1.KVPutResult, keybase1.KVGetResult]: + """ + unreserve a tool for a given day if that day is currently reserved by + the given user. + note: if you unreserve a not-added or deleted tool, it will not add the tool + """ + res = await self.lookup(team, tool) + info = json.loads(res.entry_value) if res.entry_value != "" else {} + if (day not in info) or (day in info and info[day] != user): + # failed to put because currently not reserved, or current reserver is not user + return res + expected_revision = res.revision + 1 res = await self.kvstore.put( team, self.NAMESPACE, tool, info, expected_revision ) @@ -314,46 +300,64 @@ async def list_tools(self, team) -> List[str]: return keys -async def rental_user(bot, rental, team, username): +async def basic_rental_users(bot, rental, team): + user1 = "Jo" + user2 = "Charlie" + date1 = "2044-03-12" + date2 = "2044-06-12" + date3 = "2044-06-13" + tool = "laz0rs" + res = await rental.list_tools(team) - print(res) + print("LIST TOOLS: ", res) - tool = "laz0rs" res = await rental.lookup(team, tool) print("LOOKUP: ", res) res = await rental.add(team, tool) print("ADD: ", res) + assert type(res) == keybase1.KVPutResult res = await rental.remove(team, tool) print("REMOVE: ", res) + assert type(res) == keybase1.KVDeleteEntryResult res = await rental.add(team, tool) print("ADD: ", res) + assert type(res) == keybase1.KVPutResult - res = await rental.update_reservation( - team, username, tool, "2044-03-12", reserve=True - ) + res = await rental.reserve(team, user1, tool, date1) print("RESERVE: ", res) + assert type(res) == keybase1.KVPutResult - res = await rental.update_reservation( - team, username, tool, "2044-06-12", reserve=True - ) + res = await rental.reserve(team, user1, tool, date1) + print("EXPECTING RESERVE FAIL: ", res) + assert type(res) == keybase1.KVGetResult + + res = await rental.reserve(team, user2, tool, date2) print("RESERVE: ", res) + assert type(res) == keybase1.KVPutResult res = await rental.lookup(team, tool) print("LOOKUP: ", res) - res = await rental.update_reservation( - team, username, tool, "2044-06-12", reserve=False - ) + res = await rental.unreserve(team, user1, tool, date3) + print("EXPECTING UNRESERVE FAIL: ", res) + assert type(res) == keybase1.KVGetResult + + res = await rental.unreserve(team, user1, tool, date2) + print("EXPECTING UNRESERVE FAIL: ", res) + assert type(res) == keybase1.KVGetResult + + res = await rental.unreserve(team, user1, tool, date1) print("UNRESERVE: ", res) + assert type(res) == keybase1.KVPutResult res = await rental.lookup(team, tool) print("LOOKUP: ", res) -async def concurrent_rental_users(bot, rental, team, username): +async def concurrent_rental_users(bot, rental, team): tool = "time travel machine" async def concurrent_rental_user(user_id: int): @@ -363,10 +367,11 @@ async def concurrent_rental_user(user_id: int): i = 0 while True: # keep trying to reserve for user's unique date until successful - res = await rental.update_reservation(team, user, tool, date, reserve=True) + res = await rental.reserve(team, user, tool, date) i += 1 print("{}, attempt {}, TRY TO RESERVE: {}".format(user, i, res)) if type(res) == keybase1.KVPutResult: + # success return async def pre(): @@ -398,7 +403,6 @@ async def main(): print("Starting 5_secret_storage example...") team = "yourhackerspace" - username = "yourbot" def noop_handler(*args, **kwargs): pass @@ -406,11 +410,11 @@ def noop_handler(*args, **kwargs): bot = CustomKVStoreBot(handler=noop_handler(), keybase="/home/user/keybase") rental = RentalBotClient(bot) - print("...one user does some basic rental actions...") - await rental_user(bot, rental, team, username) + print("...basic rental actions...") + await basic_rental_users(bot, rental, team) print("...multiple users try to reserve...") - await concurrent_rental_users(bot, rental, team, username) + await concurrent_rental_users(bot, rental, team) print("...5_secret_storage example is complete.") From b54c145f8e148235d46bb6cf4f86ec28b965c7f8 Mon Sep 17 00:00:00 2001 From: M Mou Date: Tue, 12 Nov 2019 18:17:54 -0800 Subject: [PATCH 15/15] fix --- examples/5_secret_storage.py | 143 +++++++++++++++++++++-------------- 1 file changed, 88 insertions(+), 55 deletions(-) diff --git a/examples/5_secret_storage.py b/examples/5_secret_storage.py index 4b12c19..9a15d36 100644 --- a/examples/5_secret_storage.py +++ b/examples/5_secret_storage.py @@ -24,7 +24,7 @@ import secrets import sys from base64 import b64decode, b64encode -from typing import Dict, List, Union +from typing import Dict, List, Tuple, Union from pykeybasebot import Bot, KVStoreClient from pykeybasebot.errors import DeleteNonExistentError, RevisionError @@ -230,65 +230,94 @@ async def lookup(self, team, tool) -> keybase1.KVGetResult: res = await self.kvstore.get(team, self.NAMESPACE, tool) return res - async def add( - self, team, tool - ) -> Union[keybase1.KVPutResult, keybase1.KVGetResult]: + async def add(self, team, tool) -> Tuple[bool, Union[keybase1.KVGetResult, None]]: + """ + returns tuple + (whether action is successful, + most recent get result if applicable) + """ res = await self.lookup(team, tool) - info = ( - json.loads(res.entry_value) if res.entry_value != "" else {} - ) # if tool already exists, propagate existing info + if res.entry_value != "": + return (True, res) # if tool already exists, return get expected_revision = res.revision + 1 - res = await self.kvstore.put( - team, self.NAMESPACE, tool, info, expected_revision - ) - return res + res = await self.kvstore.put(team, self.NAMESPACE, tool, {}, expected_revision) + if type(res) == keybase1.KVGetResult: + return (False, res) + else: + return (True, None) async def remove( self, team, tool - ) -> Union[keybase1.KVDeleteEntryResult, keybase1.KVGetResult, None]: - expected_revision = 1 + ) -> Tuple[bool, Union[keybase1.KVGetResult, None]]: + """ + returns tuple + (whether action is successful, + most recent get result if applicable) + """ res = await self.lookup(team, tool) + if res.entry_value == "": + return (True, res) # if tool already doesn't exist, return get expected_revision = res.revision + 1 res = await self.kvstore.delete(team, self.NAMESPACE, tool, expected_revision) - return res + if type(res) == keybase1.KVGetResult: + return (False, res) + else: + return (True, None) async def reserve( self, team, user, tool, day - ) -> Union[keybase1.KVPutResult, keybase1.KVGetResult]: + ) -> Tuple[bool, Union[keybase1.KVGetResult, None]]: """ reserve a tool for a given day if that day is already not reserved. note: if you reserve a not-added or deleted tool, it will add the tool + + returns tuple + (whether action is successful, + most recent get result if applicable) """ res = await self.lookup(team, tool) info = json.loads(res.entry_value) if res.entry_value != "" else {} if day in info: - return res # failed to put because day is already reserved. + return (False, res) # failed to put because day is already reserved. else: info[day] = user expected_revision = res.revision + 1 res = await self.kvstore.put( team, self.NAMESPACE, tool, info, expected_revision ) - return res + if type(res) == keybase1.KVGetResult: + return (False, res) + else: + return (True, None) async def unreserve( self, team, user, tool, day - ) -> Union[keybase1.KVPutResult, keybase1.KVGetResult]: + ) -> Tuple[bool, Union[keybase1.KVGetResult, None]]: """ unreserve a tool for a given day if that day is currently reserved by the given user. note: if you unreserve a not-added or deleted tool, it will not add the tool + + returns tuple + (whether action is successful, + most recent get result if applicable) """ res = await self.lookup(team, tool) info = json.loads(res.entry_value) if res.entry_value != "" else {} - if (day not in info) or (day in info and info[day] != user): - # failed to put because currently not reserved, or current reserver is not user - return res + if day not in info: + # a noop because currently not reserved + return (True, res) + if day in info and info[day] != user: + # failed to put because current reserver is not user + return (False, res) expected_revision = res.revision + 1 res = await self.kvstore.put( team, self.NAMESPACE, tool, info, expected_revision ) - return res + if type(res) == keybase1.KVGetResult: + return (False, res) + else: + return (True, None) async def list_tools(self, team) -> List[str]: res = await self.kvstore.list_entrykeys(team, self.NAMESPACE) @@ -308,50 +337,54 @@ async def basic_rental_users(bot, rental, team): date3 = "2044-06-13" tool = "laz0rs" + (ok, res) = await rental.remove(team, tool) + print("REMOVE: ", ok, res) + assert ok + res = await rental.list_tools(team) print("LIST TOOLS: ", res) res = await rental.lookup(team, tool) print("LOOKUP: ", res) - res = await rental.add(team, tool) - print("ADD: ", res) - assert type(res) == keybase1.KVPutResult + (ok, res) = await rental.add(team, tool) + print("ADD: ", ok, res) + assert ok - res = await rental.remove(team, tool) - print("REMOVE: ", res) - assert type(res) == keybase1.KVDeleteEntryResult + (ok, res) = await rental.remove(team, tool) + print("REMOVE: ", ok, res) + assert ok - res = await rental.add(team, tool) - print("ADD: ", res) - assert type(res) == keybase1.KVPutResult + (ok, res) = await rental.add(team, tool) + print("ADD: ", ok, res) + assert ok - res = await rental.reserve(team, user1, tool, date1) - print("RESERVE: ", res) - assert type(res) == keybase1.KVPutResult + (ok, res) = await rental.reserve(team, user1, tool, date1) + print("RESERVE: ", ok, res) + assert ok - res = await rental.reserve(team, user1, tool, date1) - print("EXPECTING RESERVE FAIL: ", res) - assert type(res) == keybase1.KVGetResult + (ok, res) = await rental.reserve(team, user1, tool, date1) + print("EXPECTING RESERVE FAIL: ", ok, res) + assert not ok - res = await rental.reserve(team, user2, tool, date2) - print("RESERVE: ", res) - assert type(res) == keybase1.KVPutResult + (ok, res) = await rental.reserve(team, user2, tool, date2) + print("RESERVE: ", ok, res) + assert ok res = await rental.lookup(team, tool) print("LOOKUP: ", res) - res = await rental.unreserve(team, user1, tool, date3) - print("EXPECTING UNRESERVE FAIL: ", res) - assert type(res) == keybase1.KVGetResult + (ok, res) = await rental.unreserve(team, user1, tool, date3) + print("UNRESERVE: ", ok, res) + assert ok - res = await rental.unreserve(team, user1, tool, date2) - print("EXPECTING UNRESERVE FAIL: ", res) - assert type(res) == keybase1.KVGetResult + (ok, res) = await rental.unreserve(team, user1, tool, date2) + print("EXPECTING UNRESERVE FAIL: ", ok, res) + assert not ok - res = await rental.unreserve(team, user1, tool, date1) - print("UNRESERVE: ", res) - assert type(res) == keybase1.KVPutResult + (ok, res) = await rental.unreserve(team, user1, tool, date1) + print("UNRESERVE: ", ok, res) + assert ok res = await rental.lookup(team, tool) print("LOOKUP: ", res) @@ -367,18 +400,18 @@ async def concurrent_rental_user(user_id: int): i = 0 while True: # keep trying to reserve for user's unique date until successful - res = await rental.reserve(team, user, tool, date) + (ok, res) = await rental.reserve(team, user, tool, date) i += 1 - print("{}, attempt {}, TRY TO RESERVE: {}".format(user, i, res)) - if type(res) == keybase1.KVPutResult: + print("{}, attempt {}, TRY TO RESERVE: {}, {}".format(user, i, ok, res)) + if ok: # success return async def pre(): while True: - res = await rental.remove(team, tool) - if type(res) == keybase1.KVDeleteEntryResult: - print("REMOVE: {}".format(res)) + (ok, res) = await rental.remove(team, tool) + if ok: + print("REMOVE: ", ok, res) break async def post():