2021-03-11 19:16:15 +01:00
|
|
|
"""TOFU implementation.
|
|
|
|
|
|
|
|
As of writing there is still some debate around it, so it is quite messy and
|
|
|
|
requires more clarity both in specification and in our own implementation.
|
|
|
|
"""
|
|
|
|
|
2021-02-12 19:01:42 +01:00
|
|
|
import hashlib
|
|
|
|
import re
|
|
|
|
from enum import Enum
|
2021-03-13 20:37:53 +01:00
|
|
|
from pathlib import Path
|
2021-04-19 02:04:18 +02:00
|
|
|
from typing import Any, Dict, Optional
|
2021-02-12 19:01:42 +01:00
|
|
|
|
2021-04-19 02:04:18 +02:00
|
|
|
from bebop.fs import get_user_data_path
|
2021-02-12 19:01:42 +01:00
|
|
|
|
2021-03-13 16:31:11 +01:00
|
|
|
|
2021-04-19 02:04:18 +02:00
|
|
|
STASH_LINE_RE = re.compile(r"(\S+) (\S+) (\S+)")
|
2021-02-12 19:01:42 +01:00
|
|
|
|
2021-04-19 02:04:18 +02:00
|
|
|
WRONG_FP_ALERT = """\
|
|
|
|
The request could not complete because the certificate presented by the server \
|
|
|
|
does not match the certificate stored in the local stash.
|
2021-02-12 19:01:42 +01:00
|
|
|
|
2021-04-19 02:04:18 +02:00
|
|
|
``` details of the fingerprint mismatch
|
|
|
|
Hostname: {hostname}
|
|
|
|
Local fingerprint: {local_fp}
|
|
|
|
Server fingerprint: {remote_fp}
|
|
|
|
```
|
|
|
|
|
|
|
|
If you are sure this new certificate can be trusted, press ":" and type the \
|
|
|
|
following command to remove the previous certificate from the local stash, \
|
|
|
|
then retry your request:
|
|
|
|
|
|
|
|
``` command to use to forget about the previous certificate
|
|
|
|
forget-certificate {hostname}
|
|
|
|
```
|
|
|
|
|
|
|
|
You can also manually remove the certificate line from the known hosts file in \
|
|
|
|
your user data directory.
|
|
|
|
|
|
|
|
## FAQ
|
|
|
|
|
|
|
|
### What is this mismatch about?
|
|
|
|
|
|
|
|
Gemini uses TOFU (Trust On First Use) to verify the identity of the server you \
|
|
|
|
are visiting. It means that the first time you visited this capsule, it showed \
|
|
|
|
you its unique ID, but this time the ID is different, so the trust is broken.
|
|
|
|
|
|
|
|
Capsule owners often tell in advance when they are about the use a new \
|
|
|
|
certificate, but they may have forgotten or you may have missed it. Maybe the \
|
|
|
|
old certificate expired and/or has been replaced for another reason (e.g. \
|
|
|
|
using a far away expiration time, borking certificates during a migration, …)
|
|
|
|
|
|
|
|
### Am I being hacked?
|
|
|
|
|
|
|
|
Probably not, but if you are visiting a sensitive capsule, make sure you're \
|
|
|
|
confident enough before trusting this new certificate.
|
|
|
|
|
|
|
|
### How to ensure this new certificate can be trusted?
|
|
|
|
|
|
|
|
Can you join the owner through mail or instant messaging? This is the simplest \
|
2021-05-12 22:29:03 +02:00
|
|
|
way for you to make sure that the server is fine, and maybe alert the server \
|
|
|
|
owner that there might be an issue.
|
2021-04-19 02:04:18 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def get_cert_stash_path() -> Path:
|
|
|
|
"""Return the default certificate stash path."""
|
|
|
|
return get_user_data_path() / "known_hosts.txt"
|
|
|
|
|
|
|
|
|
|
|
|
def load_cert_stash(stash_path: Path) -> Optional[Dict]:
|
2021-05-12 22:29:03 +02:00
|
|
|
"""Return the certificate stash from the file, or None on error.
|
2021-02-12 19:01:42 +01:00
|
|
|
|
|
|
|
The stash is a dict with host names as keys and tuples as values. Tuples
|
|
|
|
have four elements:
|
|
|
|
- the fingerprint algorithm (only SHA-512 is supported),
|
|
|
|
- the fingerprint as an hexstring,
|
|
|
|
- the timestamp of the expiration date,
|
|
|
|
- a boolean that is True when the stash is loaded from a file, i.e. always
|
|
|
|
true for entries loaded in this function, but should be false when it
|
|
|
|
concerns a certificate temporary trusted for the session only; this flag
|
|
|
|
is used to decide whether to save the certificate in the stash at exit.
|
|
|
|
"""
|
|
|
|
stash = {}
|
|
|
|
try:
|
|
|
|
with open(stash_path, "rt") as stash_file:
|
|
|
|
for line in stash_file:
|
|
|
|
match = STASH_LINE_RE.match(line)
|
|
|
|
if not match:
|
|
|
|
continue
|
2021-04-19 02:04:18 +02:00
|
|
|
name, algo, fingerprint = match.groups()
|
|
|
|
stash[name] = (algo, fingerprint, True)
|
2021-02-12 19:01:42 +01:00
|
|
|
except (OSError, ValueError):
|
|
|
|
return None
|
|
|
|
return stash
|
|
|
|
|
|
|
|
|
2021-03-13 20:37:53 +01:00
|
|
|
def save_cert_stash(stash: dict, stash_path: Path):
|
2021-03-13 16:33:04 +01:00
|
|
|
"""Save the certificate stash."""
|
|
|
|
try:
|
|
|
|
with open(stash_path, "wt") as stash_file:
|
2021-04-19 02:04:18 +02:00
|
|
|
for name, entry in stash.items():
|
|
|
|
algo, fingerprint, is_permanent = entry
|
2021-03-13 16:33:04 +01:00
|
|
|
if not is_permanent:
|
|
|
|
continue
|
2021-04-19 02:04:18 +02:00
|
|
|
entry_line = f"{name} {algo} {fingerprint}\n"
|
2021-03-13 20:37:53 +01:00
|
|
|
stash_file.write(entry_line)
|
2021-04-19 02:04:18 +02:00
|
|
|
except (OSError, ValueError) as exc:
|
|
|
|
print(f"Failed to save certificate stash '{stash_path}': {exc}")
|
2021-03-13 16:33:04 +01:00
|
|
|
|
|
|
|
|
2021-02-12 19:01:42 +01:00
|
|
|
class CertStatus(Enum):
|
|
|
|
"""Value returned by validate_cert."""
|
|
|
|
# Cert is valid: proceed.
|
|
|
|
VALID = 0 # Known and valid.
|
2021-04-19 02:04:18 +02:00
|
|
|
VALID_NEW = 1 # New and valid.
|
2021-02-12 19:01:42 +01:00
|
|
|
# Cert is unusable or wrong: abort.
|
2021-04-19 02:04:18 +02:00
|
|
|
ERROR = 2 # General error.
|
|
|
|
WRONG_FINGERPRINT = 3 # Fingerprint in the stash is different.
|
2021-02-12 19:01:42 +01:00
|
|
|
|
|
|
|
|
2021-04-19 02:04:18 +02:00
|
|
|
def validate_cert(der, hostname, cert_stash) -> Dict[str, Any]:
|
|
|
|
"""Return a dict containing validation info for this certificate.
|
2021-02-12 19:01:42 +01:00
|
|
|
|
2021-04-19 02:04:18 +02:00
|
|
|
Returns:
|
|
|
|
The validation dict can contain two keys:
|
|
|
|
- status: CertStatus, always present.
|
|
|
|
- hash: DER hash to be used as certificate fingerprint, present if status is
|
|
|
|
not CertStatus.ERROR.
|
|
|
|
- saved_hash: fingerprint for this hostname in the local stash, present if
|
|
|
|
status is CertStatus.WRONG_FINGERPRINT.
|
|
|
|
"""
|
2021-02-12 19:01:42 +01:00
|
|
|
if der is None:
|
2021-04-19 02:04:18 +02:00
|
|
|
return {"status": CertStatus.ERROR}
|
2021-02-12 19:01:42 +01:00
|
|
|
|
2021-04-19 02:04:18 +02:00
|
|
|
known = False
|
2021-02-12 19:01:42 +01:00
|
|
|
|
|
|
|
# Check the entire certificate fingerprint.
|
|
|
|
cert_hash = hashlib.sha512(der).hexdigest()
|
2021-04-19 02:04:18 +02:00
|
|
|
result = {"hash": cert_hash} # type: Dict[str, Any]
|
2021-02-12 19:01:42 +01:00
|
|
|
if hostname in cert_stash:
|
2021-04-19 02:04:18 +02:00
|
|
|
_, fingerprint, _ = cert_stash[hostname]
|
|
|
|
if cert_hash != fingerprint:
|
|
|
|
result.update(
|
|
|
|
status=CertStatus.WRONG_FINGERPRINT,
|
|
|
|
saved_hash=fingerprint
|
|
|
|
)
|
|
|
|
return result
|
|
|
|
known = True
|
|
|
|
|
|
|
|
result.update(status=CertStatus.VALID if known else CertStatus.VALID_NEW)
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
def trust_fingerprint(stash, hostname, algo, fingerprint, trust_always=False):
|
|
|
|
"""Add a fingerprint entry to this stash."""
|
|
|
|
stash[hostname] = (algo, fingerprint, trust_always)
|
|
|
|
|
|
|
|
|
|
|
|
def untrust_fingerprint(stash, hostname):
|
|
|
|
"""Remove a fingerprint entry from this stash; return True on deletion."""
|
|
|
|
if hostname in stash:
|
|
|
|
del stash[hostname]
|
|
|
|
return True
|
|
|
|
return False
|