Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add KVStoreClient, examples #28

Merged
merged 15 commits into from
Nov 13, 2019
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
dist/
__pycache__/
.mypy_cache/
*.egg-info
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ repos:
name: mypy
stages: [commit]
language: system
entry: poetry run mypy **/*.py
entry: poetry run mypy pykeybasebot/
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was getting "No directory found" for **/*.py. Also I think this should match with what is in the Makefile for make test

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could do poetry run mypy pykeybasebot examples tests (though i think i was getting errors with the tests dir)

types: [python]
pass_filenames: false
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
134 changes: 134 additions & 0 deletions examples/4_simple_storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env python3

###################################
# WHAT IS IN THIS EXAMPLE?
mmou marked this conversation as resolved.
Show resolved Hide resolved
#
# 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.
mmou marked this conversation as resolved.
Show resolved Hide resolved
#
# 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.
mmou marked this conversation as resolved.
Show resolved Hide resolved
###################################

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 <namespace> <key> <value> (<revision>)`
`!storage {get|delete} <namespace> <key>`
mmou marked this conversation as resolved.
Show resolved Hide resolved
`!storage list`
mmou marked this conversation as resolved.
Show resolved Hide resolved
`!storage list <namespace>`
mmou marked this conversation as resolved.
Show resolved Hide resolved

It expects properly formatted commands.
mmou marked this conversation as resolved.
Show resolved Hide resolved
"""

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)
mmou marked this conversation as resolved.
Show resolved Hide resolved
)

msg = event.msg.content.text.body.split(" ")
mmou marked this conversation as resolved.
Show resolved Hide resolved
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>
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 <namespace> <key>
res = await bot.kvstore.get(team, namespace, key)
await bot.chat.send(channel, str(res))
return
if action == KVMsg.DELETE.value:
# !storage delete <namespace> <key>
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 <namespace> <key> <value> (<revision>)
(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({}))
218 changes: 218 additions & 0 deletions examples/4_totp_storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
#!/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 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
import json
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"
mmou marked this conversation as resolved.
Show resolved Hide resolved


class TotpHandler:
"""
TotpHandler handles commands sent via chat and uses the team key-value store to
provide TOTP client functionality.

TotpHandler listens to chat messages of the form:

`!totp {provision|reprovision|remove|now|uri} <issuer>` and
`!totp list`

For each provisioned key, the handler stores in the namespace "totp" one
row, with the key "<issuer" and the json blob value "{"secret": <16 char base32
mmou marked this conversation as resolved.
Show resolved Hide resolved
secret>}".

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, 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.
"""

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()
mmou marked this conversation as resolved.
Show resolved Hide resolved
val = json.dumps(self.to_json(secret))
if not force:
try:
# throws exception if 1 is not the latest revision + 1
mmou marked this conversation as resolved.
Show resolved Hide resolved
await bot.kvstore.put(team, self.NAMESPACE, issuer, val, revision=1)
except Exception as e:
mmou marked this conversation as resolved.
Show resolved Hide resolved
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, self.NAMESPACE, issuer)
else:
# then TOTP for this issuer was previously provisioned and
# might still be in use, so raise the exception
raise e
mmou marked this conversation as resolved.
Show resolved Hide resolved
else:
# reprovisioning; "force" provisioning by inserting as latest revision + 1
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, self.NAMESPACE, issuer)

async def __list(self, bot, team):
# returns all namespaces in this team; assumes that all namespaces are
mmou marked this conversation as resolved.
Show resolved Hide resolved
# used for storing TOTP credentials
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)
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):
mmou marked this conversation as resolved.
Show resolved Hide resolved
res = await bot.kvstore.get(team, self.NAMESPACE, issuer)
if bot.kvstore.is_present(res):
# if secret is present
secret = json.loads(res.entry_value)["secret"]
return pyotp.TOTP(secret)
else:
return None

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 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 <issuer>"
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:
await bot.chat.send(channel, send_msg)
return
if action == TotpMsg.REPROVISION.value:
# chat: "!totp reprovision <issuer>"
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:
await bot.chat.send(channel, send_msg)
return
if action == TotpMsg.NOW.value:
# chat: "!totp now <issuer>"
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 <issuer>"
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 <issuer>"
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


username = "yourbot"

bot = Bot(
username=username, paperkey=os.environ["KEYBASE_PAPERKEY"], handler=TotpHandler()
)

asyncio.run(bot.start({}))
Loading