Multifactor Authentication
<cyli>
Ying Li
(at) Rackspace
Hi! My name is Ying. I work at Rackspace, although not on the authentication team. Thank you all for coming despite that though! I know this talk is supposed to be about multifactor authentication, but I think I'm going to go off script.
Multifactor Authentication
Alice & Bob
Instead, I'm just going to tell you a story about two people: Alice and Bob.
Bob is a gamer - he likes playing games, so he built a his own...
...an MMO called World of BobCraft. It became incredibly popular, so much so that in-game items and gold were sold on the open market for real money, making stealing accounts a pretty profitable business.
One day, Alice, a longtime elite player with lots of high level items and gold...
...reports that all her gear and items had been liquidated, and all her gold stolen by someone who hijacked her account. Bob spends all day verifying her story and restoring her account and possessions.
But every day, more and more players start reporting their accounts stolen. Bob spends all of his time restoring accounts rather than making new content. Bob is sad.
Bob reads about how companies like Github, Google, Facebook, and Blizzard added multifactor authentication to their services to increase account security.
This seems to involve asking for two things on login, so Bob adds a second password requirement to World of Bobcraft login.
But account thefts continue, and Bob continues being sad. But why? What did Bob do wrong?
Multifactor authentication
Well, multifactor authentication doesn't mean just asking for any two things to login. It requires at least two different factors. The recognized factors are:
something you know
something you have
something you are
Something you know (such as a password or PIN), something you have (such as an ATM card, phone, or a RSA token), and something you are, meaning something inherent to you, such as your fingerprints or retinal pattern.
Two of the same factors (like Bob's two required passwords) doesn't count.
The reason they doesn't count is that using multiple passwords is like putting a secret (lets say the account data you want to protect)...
...in a box (which represents a password), and ...
(WAIT FOR VIDEO)
The point of this completely legitimate and legal fair use of copyrighted material is that layers of boxes do not protect against an attacker who can open (or smash) even a single box, because they can do the same to all the boxes.
To unwrap that analogy, a keylogger will steal any number of passwords as easily as one without further cost to the attacker.
Multifactor auth is like placing a security guard in front of your box. An attacker has to be able to both get around the security guard and open the box.
Even if they have stolen the key to the box from you (your password, in this analogy), they don't get past the guard (the possession factor, in this analogy) without more work.
Each type of factor has a different set of vulnerabilities, or weaknesses, that an attacker can use to break in. It's important to consider what the attacker is going to do and how your defenses will prevent against it, rather than just adding more defenses that may not do anything.
MITM
password
Some attacks against password authentication includes MITM attacks...
... where an attacker sits between Alice and Bob, pretends he's Bob to Alice and pretends he's Alice to Bob, and reads and perhaps modifies traffic that Alice was sending to Bob or vice versa.
phishing
keylogging
replay
password
Password authentication is also vulnerable to phishing attacks, where the attacker tries to trick Alice into giving up the password, (TAP) keylogging, where the attacker logs all keystrokes on Alice's machine, and (TAP) replay, where the attacker copies network traffic from Alice to Bob, not being able to understand or modify it, but then replays the traffic back to Bob at a later point, pretending to be Alice.
MITM
phishing
keylogging
replay
password
MITM
theft
token
Possession factors are also vulnerable to MITM attacks. (TAP) They can be physically stolen, and then the attacker would be in possession of Alice's possession factor.
Various types of posession factors also have their own
vulnerabilities, but these are the general attacks against all posession factors.
stealing
replay
biometric
Biometric factors, like fingerprints can also be stolen. People leave them everywhere, so an attacker can easily obtain them. Since fingerprints don't change, the credentials can never be revoked, and they are vulnerable to replay attacks.
2 FA
something you know
something you have
But this talk is just going to discuss 2 factor authentication (2FA, if you ever see this acronym) using knowledge and possession factors, because they are currently the most common.
phishing
keylogging
replay
password
theft
token
MITM
When two factors are combined, the attacks against two-factor auth becomes the intersection of the attacks against one factor and the attacks against the other. So an attack that works against only one factor is insufficient to compromise login.
phishing
replay
keylogging
password
theft
token
MITM
If an attacker keylogs Alice's password, login is still protected by the token factor.
And the way most posession factors work is they produce a one-time password which can only be used one time, so even if an attacker gets Alice's complete login (by which I mean password and token), they can't use the token.
theft
token
MITM
phishing
keylogging
replay
password
Likewise, if the possession factor is stolen, the login is still protected by the password.
phishing
keylogging
replay
password
theft
token
MITM
The goal is to minimize this intersection of attacks. That's why Bob has to include at least two factors. If the same factor, say passwords, is used twice...
MITM
phishing
keylogging
replay
password
password
... the intersection of attacks is the same as that for single factor password authentication. But just because two factors are used doesn't mean that the intersection is as small as it could be.
Apps that provide soft tokens, like Google Authenticator, Authy, and Duo Security can run on phones and so make it really easy for Alice to use 2-factor auth without buying a hard token. But phones are general purpose computing devices, which could be exploited, so the token and the password can be compromised at the same time.
phishing
keylogging
replay
password
theft
token
MITM
so this...
(tap through this slide quickly)
...
password
...
token
MITM
root phone
Actually becomes this. When using a soft token on a phone, the intersection contains one more attack - compromising the phone. As you can see, there are still fewer attacks than in single factor password authentication. So it's not that Alice shouldn't use soft tokens, but Alice should be aware of the other attack and be careful with her phone's security if she does so.
Now knowing what multifactor authentication is, Bob surveys other sites that provide it to find the most commonly supported possession factors.
These possession factors all produce tokens that are one-time passwords, or OTPs, meaning they expire after a brief time, they expire after a single use, or both.
The most common soft tokens on smartphones, supported by the previously mentioned apps...
OATH
(open authentication)
TOTP
(time-based one time password)
Are open authentication time-based one time passwords (TOTPs), which usually expire after 30 seconds. OATH is not OAuth - this is AUTHENTICATION, OAuth for AUTHORIZATION. I know, so clear, right?
TOTP(<secret key>, <time counter>)
(RFC 6238, RFC 4226)
TOTP, again, time-based one time passwords, require a shared secret between the client and server, and are generated using the secret and the current time. The TOTP generated by Alice's app is valid if it matches the TOTP generated by World of Bobcraft. The algorithm is pretty short and simple, specified in RFCs 6238 and 4226.
pip install cryptography
or
pip install otpauth
or
...
There are many python libraries that support OATH. Cryptography not only supports it, but many other cryptographic capabilities. otpauth just supports OATH, and includes an example of how to generate a QR code from the shared secret.
Many sites also support SMS based one-time passwords. Bob would text Alice a random OTP that she must provide back. This works even if Alice doesn't have a smartphone.
pip install twilio
Twilio provides a REST API that can let Bob send any SMS to Alice, allowing him to support SMS-based authentication. Any HTTP client can be used, but Twilio also provides a python twilio client.
A commonly supported consumer hard token is the Yubikey, which acts as a USB keyboard. You just plug it in...
...press the button, and it generates an OTP based on a counter. Yubico provides a validation service, as well an open source validation server that Bob could run on his own.
pip install yubikey
or
pip install yubico-client
or
...
There are quite a few python client libraries that provide access to the validation API, the official one being the first one.
add 2FA to account
validate 2FA on login
remove 2FA from account
Ok, so what does Bob need to do to support possession factors? He has to let Alice add factors to her account, to validate the factor as well as the password when Alice logs in, and to let Alice to remove factors from her account.
Bob figures out the general pattern to add 2-factor auth from scratch to a general python application, which also applies to apps using frameworks like Flask or Twisted.
assuming...
class User(object):
"""Represents a user account"""
username = "" # user's name/id
possession_factors = () # in preference order
def validate_password(self, pwd):
"""Validates the password against
the stored hash"""
def email(self, *message):
"""Emails the user"""
def exists(self):
"""Whether a user exists"""
The next few slides are going to assume a user model that looks something like this. It has an collection of possession factors in order of preference of Alice because Bob would like to support backup devices, in case Alice loses her primary device, which is the first factor in the collection.
... assuming...
class Factor(object):
"""Represents a type of factor or device"""
prompt = "" # what to ask user
def start(self):
"""Preps the factor, such as SMS - does
nothing for other factors"""
def check_otp(self, otp):
"""Validates that the OTP is valid
for this factor"""
All of Bob's factors will have this interface. They all have a function to check whether an OTP is valid, and because some factors require setup before every OTP, a start function that does the setup.
The prompt is for later, when Bob has to ask Alice for her OTP on login.
TOTP
First we're going to TOTP.
=?
When first setting up the factor, Bob generates a shared secret, communicates it to Alice via a QR code.
Then whenever Alice wants to authenticate using this factor, she generates a TOTP from the shared secret, which she gives to Bob to match against the TOTP that he produces with the same secret.
TOTP Factor
from os import urandom
from otpauth import OtpAuth
class TOTPFactor(object):
prompt = "token?"
def __init__(self, secret=None):
self.secret = secret or urandom(40)
def start(self): pass
def check_otp(self, otp):
otpa = OtpAuth(self.secret)
return otpa.valid_totp(otp)
For the factor code, Bob generates a random secret, and he can use the otpauth library to check the validity of a token given this secret. There is no setup except when first configuring the factor, so the start function does nothing.PAUSE
add 2FA to account: TOTP
from bobcraft.totp_factor import TOTPFactor
from bobcraft.made_up import get_user_input
def add_totp(user):
totp = TOTPFactor()
qr_code = generate_qr_code(totp, user.username)
totp_token = get_user_input(prompt=qr_code)
if totp.check_otp(totp_token):
user.possession_factors += (totp,)
To configure this factor, Bob sends Alice a QR code as per the diagram, waits for a response, and if the token checks out, Bob adds the factor to her account.
The get_user_input function here is synchronous, and also not implemented in the slides, hence the 'made_up' module. For web apps, the QR code and a form would have to be rendered on a page, so in reality asking for user input is probably asynchronous, stateful, and more complex.
otpauth://type/label? secret=...&issuer=...
label = issuer: account_name
Speaking of QR codes, the general form of the URI to be encoded in the QR code looks like this. It specifies the algorithm used, the issuer, and the username. Alice's app may generate OTPs for multiple services. This helps the app to label the OTP as for World of Bobcraft, so Alice can tell it apart from the OTP for World of CarolCraft.
generate_qr_code
from otpauth import OtpAuth
import qrcode
def generate_qr_code(totp, username):
otpa = OtpAuth(totp.secret)
uri = otpa.to_uri(
'totp', 'BobCraft:{0}'.format(username),
'BobCraft')
return qrcode.make(uri)
otpauth generates such a URI when given the relevant information. So this is what the generate_qr_code function looks like.
SMS
For the SMS factor, Bob uses Twilio to send SMSes.
#
123
456
(
)
=?
The way SMS tokens work is that Alice gives Bob her phone number. Bob generates some random token, and texts it to Alice at that number. The token that Alice gives back to Bob has to match what Bob texted to her.
SMS Factor
from bobcraft.twilio_creds import (
account_sid, auth_token, from_number)
from twilio.rest import TwilioRestClient
class SMSFactor(object):
prompt = "code from text?"
def __init__(self, user_phone):
self.user_phone = user_phone
self.stored = None
self.client = TwilioRestClient(
account_sid, auth_token)
...
Bob first needs an account with Twilio so that he can provide his account ID and auth token to the REST client. He also needs a Twilio number he can send SMS's from...
SMS Factor (cont.)
...
def start(self):
from random import SystemRandom
self.stored = SystemRandom().randint(1, 999999)
self.client.sms.messages.create(
body=str(self.stored),
to=self.user_phone, from_=from_number)
def check_otp(self, otp):
return int(otp) == self.stored
Because the start function stores the generated OTP and texts it to Alice at her number, from the Bob's number. Validating the OTP is just checking it matches the stored one.
This is a naive implementation doesn't take into account sending Alice multiple tokens, token expiry, or API failures, but this is the general idea.
add 2FA to account: SMS
from bobcraft.sms_factor import SMSFactor
from bobcraft.made_up import get_user_input
def add_sms(user):
user_phone = get_user_input(prompt="phone #")
sms = SMSFactor(user_phone)
if sms not in user.possession_factors:
sms.start()
sms_token = get_user_input(prompt="token")
if sms.check_otp(sms_token):
user.possession_factors += (sms,)
There is one extra check in this add factor function - the configuration only continues if the phone number isn't already configured Alice's account. If it is, then the setup bails. But otherwise, this proceeds like the diagram. Bob texts Alice, if Alice gives back a valid OTP, the phone number is added to Alice's account as a factor.
Yubikey
Now for the Yubikey factor.
fifjgjgkhchb
irdrfdnlnghhfgrtnnlgedjlftrbdeut
public ID
OTP
up to 128 bits
128 bits (32 characters)
And before we begin it's good to know that every time the Yubikey button is pressed, an OTP is produced that has the ID of the Yubikey, which is the same every time, concatenated to the actual OTP part, which changes every time, and is exactly 32 characters.
So the ID, which is variable in length, can be extracted from the full OTP by chopping off the last 32 characters.
=?
and
yubico
Whenever Alice provides a Yubikey OTP, Bob checks it with Yubico's servers, and also extracts the the ID of the Yubikey. If the ID matches the one Alice previously registered, then Alice is using the same Yubikey she registered before. And then if Yubico also says it's a valid OTP, then Alice is authenticated.
Yubikey Factor
from bobcraft.yubikey_creds import (
client_id, secret_key)
from yubico_client import Yubico
class YubikeyFactor(object):
prompt = "press yubikey button"
def __init__(self, yubikey_id):
self.yubikey_id = yubikey_id
def start(self): pass
...
To validate against Yubico, Bob has to generate client ID and secret symmetric key for World of Bobcraft. This key is used by Yubico to sign responses, so that Bob knows whether a response is from Yubico or is spoofed by an attacker.
And like the TOTP factor, the start function does nothing.
Yubikey Factor (cont.)
...
def check_otp(self, otp):
client = Yubico(client_id, secret_key)
return (get_public_id(otp) == self.yubikey_id
and client.verify(otp))
def get_public_id(otp): # module level function
return otp[:-32]
The check_otp code just does what that diagram described - it validates the OTP (the client validates the signature), extracts the Yubikey ID, and checks it against the stored ID.
Again, this is a simplified implementation that does not take into account API or signature failures.
add 2FA to account: Yubikey
from bobcraft.yubikey_factor import (
YubikeyFactor, get_public_id)
from bobcraft.made_up import get_user_input
def add_yubikey(user):
otp = get_user_input(prompt="Yubikey OTP")
yubikey = YubikeyFactor(get_public_id(otp))
if (yubikey not in user.possession_factors and
yubikey.check_otp(otp)):
user.possession_factors += (yubikey,)
When adding a Yubikey, Alice just needs to provide the OTP. If a factor with that Yubikey ID hasn't been configured already, and the OTP validates, Bob can add the Yubikey ID as a factor to her account.
validate 2FA on login
Now for validating a token on login. This part is simpler because most of the work was already done in the factor abstractions, which is what made the previous part somewhat lengthy.
user
pass
(
)
and
?
The logic is Alice provides Bob with a username and password. Bob validates that Alice is a user and that her password checks out. He also sees if Alice has a second factor enabled. If so, Bob asks for the second factor credentials, and validates it too.
login
from bobcraft.user import User
def login(username, password, post_login):
user = User(username)
if (user.exists() and
user.validate_password(password) and
check_possession_factor(user)):
post_login(user)
else:
raise InvalidLogin(username)
Here Bob starts off with a login function, which checks the user, the password, and the possession factor. Bob also makes it takes post_login argument, which is a callable that will execute post-login logic.
validate preferred token on login
from bobcraft.made_up import get_user_input
def check_possession_factor(user):
if len(user.possession_factors) == 0:
return True
factor = user.possession_factors[0]
factor.start()
otp = get_user_input(prompt=factor.prompt)
return factor.check_otp(otp)
Checking the possession factor just means that Bob sees whether Alice has at least one configured possession factor. If so, he asks for an OTP, validates it using the preferred factor, which is the first factor in the collection. If everything is good, the post-login logic executes.
Remember how the each factor has a start function for setup and a prompt to ask for the user's token? It's so that this function can be factor-independent.
validate backup token on login
from bobcraft.made_up import get_user_choice
def check_possession_factor(user):
if len(user.possession_factors) == 0:
return True
factor = get_user_choice(user.possession_factors)
factor.start()
otp = get_user_input(prompt=factor.prompt)
if factor != user.possession_factors[0]:
user.email("Login with backup factor", factor)
return factor.check_otp(otp)
To support backup devices, Bob asks for both the type of factor and the token. The default factor is the preferred factor, but Alice can pick another. The token is validated no matter what, but if the factor she picked is not her preferred factor, an email is sent warning her that a backup factor was used, since this should be an unusual occurrence.
remove 2FA from account
And now with both the factor abstractions and login implementation established, revokation is even simpler. It doesn't even need a diagram.
remove factor
def remove_factor(user, factor):
user.possession_factors = (
f for f in user.possession_factors
if f != factor)
user.email("A factor has been removed", factor)
Removing a factor is just deleting it from a collection. But because this could be an attacker making a change to decrease account security, a warning should be sent to Alice whenever a factor is removed.
secure remove factor
from functools import partial
from bobcraft.login import login
def secure_remove_factor(user, factor):
password = get_user_input(prompt="password")
login(user.username, password,
partial(remove_factor, factor=factor))
Even better would be if high privilege functionality like this require Alice to log in again. Remember how the login function took a callable that executed post-login actions? In more secure remove factor function, that callable can the previous non-sudoed remove_factor function.
https://github.com/cyli/bobcraft
So... that was adding 2-factor auth from scratch to a toy application. The full code is up on my github repo to run as a little command line demo.
https://www.twilio.com/blog/ 2013/04/add-two-factor-authentication-to-your-website-with-google-authenticator-and-twilio-sms.html
For something more Flask-specific, the Twilio blog has a very nice example of how to add TOTPs and SMS factors to a Flask app.
Certain parts of World of Bobcraft use Django, though, and there is Django middleware that handles 2-factor authentication for you.
pip install django-otp
pip install django-otp-twilio
pip install django-otp-yubikey
One of the most complete plugins is django-otp, a framework which provides support for all the previously mentioned authentication methods, although twilio and yubikey support are separate plugins. It has models for Devices, it updates the user model to include whether 2-factor auth is enabled, and has a login form template. All Bob needs is to install them...
settings.py
MIDDLEWARE_CLASSES = [
...
'django_otp.middleware.OTPMiddleware'
]
INSTALLED_APPS = [
...
'django_otp',
'django_otp.plugins.otp_totp',
'django_otp.plugins.otp_static',
'otp_twilio', # optional
'otp_yubikey' # optional
]
And add some middleware classes and installed apps get added to his Django app's settings.py.(pause)
pip install django-two-factor-auth
(no yubikey support)
There is another project, django-two-factor-auth, which integrates
django-otp into Django's built-in authentication framework and makes it easy to patch
the login view to use multifactor login.
It does not yet provide UI support for Yubikey devices, though.
settings.py
from django.core.urlresolvers import reverse_lazy
LOGIN_URL = reverse_lazy('two_factor:login')
INSTALLED_APPS = [
...
'django_otp',
...
'two_factor'
]
...
Bob just adds some more installed_apps and a login URL to settings.py (pause) ...
settings.py
...
TWO_FACTOR_CALL_GATEWAY = TWO_FACTOR_SMS_GATEWAY = (
'two_factor.gateways.twilio.Twilio')
OTP_TWILIO_ACCOUNT = '' # Twilio account sid
OTP_TWILIO_AUTH = '' # Twilio account token
OTP_TWILIO_FROM = '' # phone number with Twilio
Along with credentials for his Twilio account (pause) ...
urls.py
from two_factor.urls import urlpatterns as tf_urls
from two_factor.gateways.twilio.urls import (
urlpatterns as tf_twilio_urls)
urlpatterns = patterns(
...
url(r'', include(tf_urls + tf_twilio_urls,
'two_factor')),
)
He adds the two factor url route as the login route...(pause)
And now, Alice, when logged in, can now enable two factor authentication...
And configure her preferred factor.
django-otp
If the configuration slides went by quickly, don't worry, the docs for django-otp describes installation and configuration in detail. (pause)
add 2FA to account
validate 2FA on login
remove 2FA from account
Bob could also have used a service like Duo Security or Authy, which would handle Alice's second-factor completely. Bob has to register with one of these services, let them know about Alice...
(TAP)
And the service does all of these things that Bob had to handle by himself before. Alice has to register her devices with the service instead of World of Bobcraft. But when she logs in, Bob validates her password and the service handles the second factor.
Happily Ever After?
So now, authentication secure, Bob lives happy ever after creating content, and Alice retains full control of her dungeon loot. Right?
login
session
key
api key
Well... there are several ways of authenticating a player to World of Bobcraft other than login...
session
key
...which Bob has protected using multifactor authentication. Not every request made to World of Bobcraft requires a password and token, so an attacker can use (TAP) some other form of authentication. For instance, after logging in, World of Bobcraft saves a (TAP) session key for Alice to authenticate her on subsequent requests until she logs out. An attacker who (TAP) eavesdrops this key then has access to Alice's account.
TLS
tls certificate
Eavesdropping can be mitigated using TLS (also know as SSL) to encrypt all traffic. However, encrypted traffic won't help if Alice is man-in-the-middled with an attacker who pretends to be World of BobCraft. Bob provides (TAP) a valid TLS certificate and Alice's client must verify it (very important), in order to mitigate the the threat of a MITM attack.
goto fail
But, as we've seen from this year alone, and from Hynek's talk earlier on TLS, libraries that provide TLS support can have vulnerabilities, some of which negate the point of using it in the first place. Both Alice and Bob have to respond immediately to any required patching of TLS libraries, and keep everything up to date.
password
token
MITM
... that protects login from MITM attacks too!
MITM
TLS
tls certificate
api key
Another type of authentication is (TAP) API keys, or access tokens (such as OAuth, O A U T H), for applications that need to authenticate in the background, without any interaction with the user.
For instance, the World of BobCraft mobile app, which allows Alice to check her auctions and in-game mail.
API keys should be:
revokable and auditable
limited access (least privilege)
Bob makes sure that every app has to request a different API key, so that they are all revokable (and auditable) independently of each other. Bob also limits how much access each API key has, giving it only as much privilege as it needs, so that an attacker with a mobile API key does not have full access to the account.
@otp_required
django-otp
Bob makes use of django-otp's otp_required decorator, which behaves like django's login_required decorator, but requires that the user have to be logged in using multifactor authentication.
Password/Factor changes:
require full credentials
warn user
Potentially dangerous changes, like changing passwords, token factors, and email addresses, can compromise the account. Changes like these would be good candidates for requiring the user be logged in with multifactor auth. Even better would be to require Alice to provide credentials again, as she would for sudo commands.
And all such dangerous changes should result in a warning email being sent to Alice, in case they are taken by an atacker and not by her. And Alice, more vigilant after having her account stolen, always pays careful attention to these emails even though they seem be annoying.
MITM
TLS
tls certificate
By doing all these things, Bob protects access to Alice's account as much as possible while keeping usablility and ease of access high.
As mentioned before World of Bobcraft provides a recovery mechanism in case Alice has to change her credentials. Account recovery is just login again - anyone who 'recovers' an account is resetting the login credentials.
Recovery should be:
Recovery should be auditable and rare. All such changes all be logged so that malicious attempts to reset credentials can be identified. And reset should also be difficult, moreso than regular login, or all attackers will just go after account reset (as demonstrated by tales of account hijacking like that of Matt Honan) which is why Bob keeps obsessing over it.
Standard recovery methods:
backup devices
email reset
Bob has provided backup devices, so that Alice can use a backup token to log in and disable the primary token. (TAP) (which are supported by django-two-factor-auth) Bob could also email Alice with a reset link, forcing Alice to prove that she has control over her associated email account. This is a standard mechanism that services with only single factor auth provide. Services with multifactor auth, if the user does not have backup devices configured, often fall back on email reset. Unfortunately, email is often not gated by multifactor authentication, this turns into the something you know/something you know case that Bob started with.
2FA
backup factors
passwords
protect email
protect phone
system updates
Alice set up 2-factor auth for World of Bobcraft, and set up backup factors so that she wouldn't have to resort to email reset. She has chosen different, strong passwords for both World of Warcraft and for her email. She's set up 2-factor auth with her email too, because she wants to actually get warnings and potential resets. She is careful about what she does to her phone, sets up a lock screen and remote wipe to protect her soft tokens. And she keeps her systems up to date so that any vulnerabilities can be patched as soon as possible.
She has not had any more incidents of account theft, and is now the highest level wizard-sniper in World of Bobcraft.
2FA
account reset
TLS
limit privilege
system updates
Bob has implemented and started promoting multifactor authentication to his players. He provides secure and auditable account reset options, encrypts all traffic, limits privileges of less secure forms of authentication, and keeps all the crypto libraries needed to provide said security up to date. Account theft is down in World of BobCraft (but not gone, because not every player is careful like Alice). He spends a much higher percentage of his time designing content.
Fin
github: cyli twitter: cyli
And both live happily, if vigilantly, ever after. Thank you all for coming.