Files
edx-platform/common/djangoapps/util/log_sensitive.py
Tim McCormack a1b09c0b8d fix: More resilience when calling encrypt_for_log with missing key (#29878)
It's likely that someone will at some point enable encrypted logging but
forget to deploy the config change that sets the key; if this happens, we
should gracefully return a warning rather than raise an exception.

Along the same lines, make sure that safe-sessions won't raise an exception
if the setting is missing, and document the suggested use of getattr.
2022-02-07 16:00:56 +00:00

209 lines
7.6 KiB
Python

"""
Utilities for logging sensitive debug information such as authentication tokens.
Usage:
1. Generate keys using ``python3 -m common.djangoapps.util.log_sensitive gen-keys``
2. Follow the instructions it prints out, and pay close attention to the warning
at the end of the output
3. When logging sensitive information, use like so::
logger.info(
"Received invalid auth token %s in Authorization header",
encrypt_for_log(token, getattr(settings, 'YOUR_DEBUG_PUBLIC_KEY', None))
)
This will log a message like::
Received invalid auth token [encrypted: ZXI...fFo=|IYS...1KA==] in Authorization header
4. If you need to decrypt one of these messages, save the encrypted portion
to file, retrieve the securely held private key, and run
``python3 -m common.djangoapps.util.log_sensitive decrypt --help``
for instructions.
"""
from base64 import b64decode, b64encode
import click
from nacl.public import Box, PrivateKey, PublicKey
# Background:
#
# The NaCl "Box" construction provides asymmetric encryption, allowing
# the sender to encrypt something for a recipient without having a
# shared secret. This encryption is authenticated, meaning that the
# recipient verifies that the message matches the sender's public key
# (proof of sender). But it's also *repudiable* authentication; the
# design allows both the sender and the receiver to read (or have
# created!) the encrypted message, so the receipient can't prove to
# anyone *else* that the sender was the author.
#
# Why we use ephemeral sender keys:
#
# The Box is normally an ideal construction to use for
# communications. However, we don't want the logger to be able to read
# the messages it writes, especially not messages from a different
# server instance or from days or weeks ago. Only developers (or
# others) in possession of the recipient keypair should be able to
# read it, not anyone who compromises a server at some later
# date. Luckily, we also don't care about authenticating the logged
# messages as truly being from the server! The solution is for each
# server to create a fresh public/private keypair at startup and to
# include a copy of the public key in any encrypted logs it writes.
# Generate an ephemeral private key for the logger to use during this
# logging session.
logger_private_key = PrivateKey.generate()
def encrypt_for_log(message, reader_public_key_b64):
"""
Encrypt a message so that it can be logged using the given public key,
but only read by someone possessing the matching private key. The
public key is provided in base64.
A separate keypair should be used for each recipient or purpose.
Returns a string <sender public key> "|" <ciphertext> wrapped in
some framing text "[encrypted: ...]"; the inner string can be
decrypted with decrypt_log_message.
For ease of use, key may be None or empty; a warning message will be
returned instead of data, encrypted or otherwise.
"""
# If no key was configured, don't raise an error. This method is
# expected to be called from debugging code, so it shouldn't blow
# up on misconfiguration.
if not reader_public_key_b64:
return '[encryption failed, no key]'
reader_public_key = PublicKey(b64decode(reader_public_key_b64))
encrypted = Box(logger_private_key, reader_public_key).encrypt(message.encode())
pubkey = logger_private_key.public_key
combined = b64encode(bytes(pubkey)).decode() + '|' + b64encode(encrypted).decode()
# The goal of this framing text is to make it always clear in log
# messages that the information is encrypted
return f"[encrypted: {combined}]"
def decrypt_log_message(encrypted_message, reader_private_key_b64):
"""
Decrypt a message using the private key that has been stored somewhere
secure and *not* on the server.
"""
reader_private_key = PrivateKey(b64decode(reader_private_key_b64))
sender_public_key_data, encrypted_raw = \
[b64decode(part) for part in encrypted_message.split('|', 1)]
return Box(reader_private_key, PublicKey(sender_public_key_data)).decrypt(encrypted_raw).decode()
def generate_reader_keys():
"""
Utility method for generating a public/private keypair for use with these
logging functions.
"""
reader_private_key = PrivateKey.generate()
return {
'public': b64encode(bytes(reader_private_key.public_key)).decode(),
'private': b64encode(bytes(reader_private_key)).decode(),
}
@click.group()
def cli():
pass
@click.command('gen-keys', help="Generate keypair")
def cli_gen_keys():
"""
Generate and print a keypair for handling sensitive log messages.
"""
reader_keys = generate_reader_keys()
public_64 = reader_keys['public']
private_64 = reader_keys['private']
print(
"This is your PUBLIC key, which should be included in the server's "
"configuration. Create a separate setting (and keypair) for each "
"distinct project or team. This value does not need special protection:"
"\n\n"
f" settings.<YOUR_DEBUG_PUBLIC_KEY> = \"{public_64}\""
"\n\n"
"This is your PRIVATE key, which must never be present on the server "
"and should instead be kept encrypted in a separate, safe place "
"such as a password manager:"
"\n\n"
f" \"{private_64}\" (private)"
"\n\n"
"WARNING: Before logging anything sensitive, get a legal/compliance review to "
"ensure this is acceptable in your organization. Encryption is not "
"generally a replacement for retention policies or other privacy "
"safeguards; using this utility does not automatically make sensitive "
"information safe to handle."
)
@click.command('decrypt', help="""Decrypt a logged message.
If possible, use bash process indirection to keep the private key from
touching disk or shell history unencrypted. The safest way is to keep
the private key in an encrypted file:
--private-key-file <(gpg2 --decrypt auth-logging-key.enc)
Alternatively, you could copy it from a password manager to your
clipboard and use a CLI clipboard tool to retrieve it:
\b
--private-key-file <(xsel -bo) # Linux
--private-key-file <(pbpaste) # Mac
Another option is to somehow get the private key into an environment
variable and echo it out:
--private-key-file <(echo "$PRIVATE_KEY")
The same techniques can also be used for the encrypted message data,
which is less sensitive but should also be handled with care.
""")
@click.option(
'--private-key-file', type=click.File('r'), required=True,
help="Path to file containing reader's private key in Base64",
)
@click.option(
'--message-file', type=click.File('r'), required=True,
help="Path to file containing encrypted message, or - for stdin",
)
def cli_decrypt(private_key_file, message_file):
"""
Decrypt a message and print it to stdout.
"""
print(decrypt_log_message(message_file.read(), private_key_file.read()))
@click.command('encrypt', help="Encrypt a one-off message (for testing)")
@click.option('--public-key', help="Reader's public key, in Base64")
@click.option(
'--message-file', type=click.File('r'), required=True,
help="Path to file containing message to encrypt, or - for stdin",
)
def cli_encrypt(public_key, message_file):
"""
Encrypt a message to the provided public key and print it to stdout.
This is just intended for use when testing or experimenting with the decrypt command.
"""
print(encrypt_for_log(message_file.read(), public_key))
cli.add_command(cli_gen_keys)
cli.add_command(cli_decrypt)
cli.add_command(cli_encrypt)
if __name__ == '__main__':
cli()