Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@ VERKADA_API_KEY=This should be your API Key==
ELASTIC_PASSWORD=At least six characters long
KIBANA_SYSTEM_PASSWORD=At least six characters long and you will need to set in Kibana refer to README
KIBANA_ENCRYPTION_KEY=Create Key using command "openssl rand -base64 32" in linux or wsl==
EMAIL_PASSWORD = Password for the email account you are sending the dashboard from
AZURE_TENANT_ID=Directory (tenant) ID from your Microsoft Entra app registration
AZURE_CLIENT_ID=Application (client) ID from your Microsoft Entra app registration
AZURE_CERT_PATH=Path to the PEM file holding the private key + certificate uploaded to the app registration
WINDOWS_IP=This is the IP for your device
116 changes: 115 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,119 @@ Follow this [guide](https://docs.docker.com/desktop/features/wsl/) to set up Doc
Clone the VerityBot git repository into the WSL2 terminal
```git clone git@github.com:VerCalBot/VerityBot.git```

### <a name="Microsoft 365 email setup">Microsoft 365 email setup 📧</a>

VerityBot emails the Kibana dashboard link on a schedule. Because Microsoft 365
has **disabled Basic Authentication (username/password) for SMTP**, VerityBot
authenticates to Microsoft 365 using **OAuth2 with an app registration**
(app-only / client-credentials flow, using a **certificate**). No mailbox
password is used or stored.

You must complete the steps below **before** running `initial_setup.sh`, because
the setup dialogue box asks for the **Tenant ID**, **Client ID**, and
**Certificate Path** produced here. These steps require a **Microsoft 365 /
Entra ID administrator**.

> The exact wording of some portal buttons changes over time; the resource names
> (`Office 365 Exchange Online`, permission `SMTP.SendAsApp`, scope
> `https://outlook.office365.com/.default`) are the parts that matter.

#### 1. Choose or create the sender account
Pick the mailbox that reports will be sent *from* (this is the `Email Sender`
value in the dialogue box). This can be a normal licensed user or, preferably, a
dedicated **service account** / **shared mailbox** (e.g. `veritybot@yourdomain`).
It must have an Exchange Online mailbox.

#### 2. Register an application in Microsoft Entra ID
1. Go to the [Microsoft Entra admin center](https://entra.microsoft.com) →
**Identity → Applications → App registrations → New registration**.
2. Name it (e.g. `VerityBot Email Sender`), leave redirect URI empty, and
register. (No redirect URI / SSO is needed for app-only auth.)
3. On the app **Overview** page, copy the **Directory (tenant) ID** and the
**Application (client) ID** — these become `AZURE_TENANT_ID` and
`AZURE_CLIENT_ID`.

#### 3. Grant the SMTP send permission (admin consent required)
1. In the app, go to **API permissions → Add a permission → APIs my
organization uses**, and search for **Office 365 Exchange Online**.
2. Choose **Application permissions**, select **`SMTP.SendAsApp`**, and add it.
3. Click **Grant admin consent for &lt;tenant&gt;** and confirm the permission
shows a green "Granted" status.

#### 4. Create and upload a certificate
Generate a self-signed certificate on the machine that will run VerityBot (in
WSL). This produces two files: a **private key + certificate** PEM that stays on
the VerityBot host, and a **public certificate** (`.crt`) that you upload to
Entra.

```bash
# Creates veritybot.key (private key), veritybot.crt (public cert), valid 2 years.
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout veritybot.key -out veritybot.crt \
-days 730 -subj "/CN=VerityBot Email Sender"

# Combine the private key + certificate into one PEM for VerityBot to load.
cat veritybot.key veritybot.crt > veritybot.pem
chmod 600 veritybot.key veritybot.pem # keep the private key readable only by you
```

1. In the app, go to **Certificates & secrets → Certificates → Upload
certificate**, and upload **`veritybot.crt`** (the public cert only — never
upload the `.key` or `.pem`).
2. Note the absolute path to **`veritybot.pem`** on the VerityBot host — this is
the `Certificate Path` you enter in the dialogue box (`AZURE_CERT_PATH`).
> Certificates expire (2 years above). Before the expiry date, generate a new
> cert, upload the new `.crt`, and re-run the setup dialogue box (or just
> point `AZURE_CERT_PATH` at the new `.pem`), or the nightly email will start
> failing. Choose a longer `-days` value if you prefer fewer rotations.

#### 5. Scope the app to the sender mailbox and enable SMTP AUTH
This restricts the app so it can only send as your sender mailbox, and ensures
SMTP client submission is allowed for that mailbox. Run these in
[Exchange Online PowerShell](https://learn.microsoft.com/powershell/exchange/connect-to-exchange-online-powershell)
as an Exchange administrator:

> **Is there a web UI for this instead?** Mostly no. `New-ServicePrincipal`
> (registering the app in Exchange) and granting that service principal
> `FullAccess` to the mailbox are **PowerShell-only** — Microsoft provides no
> admin-center UI for them, and the Exchange delegation picker only lists users
> and groups, not app service principals. The **last** command, enabling
> Authenticated SMTP, *does* have a UI: Microsoft 365 admin center →
> **Users → Active users** → select the sender account → **Mail** →
> **Manage email apps** → check **Authenticated SMTP** → **Save changes**.

```powershell
Connect-ExchangeOnline

# Register the app's service principal in Exchange Online.
# AppId = Application (client) ID from step 2.
# ObjectId = the *Enterprise application* Object ID for this app
# (Entra → Enterprise applications → your app → Object ID).
New-ServicePrincipal -AppId <AZURE_CLIENT_ID> -ObjectId <ENTERPRISE-APP-OBJECT-ID> -DisplayName "VerityBot Email Sender"

# Allow the app to send only as the sender mailbox.
Add-MailboxPermission -Identity "veritybot@yourdomain.com" -User <ENTERPRISE-APP-OBJECT-ID> -AccessRights FullAccess

# Make sure SMTP AUTH is enabled for the sender mailbox.
Set-CASMailbox -Identity "veritybot@yourdomain.com" -SmtpClientAuthenticationDisabled $false
```

> If your tenant disables SMTP AUTH org-wide (the default), the per-mailbox
> `Set-CASMailbox` override above re-enables it for just this one mailbox.

#### 6. Confirm the sender domain is mapped
VerityBot connects to `smtp.office365.com` for Microsoft 365 domains. If your
sender domain is not already listed in `domain_to_server_mapping` in
`src/email_sender.py`, add it there mapping to `office365.com`.

You now have the three values the dialogue box needs:

| Dialogue box field | Value from above | `.env` key |
| ------------------ | -------------------------------------- | ----------------- |
| Tenant ID | Directory (tenant) ID (step 2) | `AZURE_TENANT_ID` |
| Client ID | Application (client) ID (step 2) | `AZURE_CLIENT_ID` |
| Certificate Path | Path to `veritybot.pem` (step 4) | `AZURE_CERT_PATH` |

### <a name="Initial Setup">Initial Setup 🧑‍💻</a>
Ensure that Docker Desktop is open and running on your host machine \
<img src="./docs/README-Images/DockerRunning.png" alt="Docker Running" title="Docker Running">
Expand All @@ -35,7 +148,8 @@ When prompted, enter your User password \

Enter your credentials into the popup dialogue box
**Note**: All fields must be filled, use the ```Generate Key``` button to create a Kibana Encryption Key.
**Note**: All passwords must be at least 6 characters long. \
**Note**: All passwords must be at least 6 characters long.
**Note**: The ```Tenant ID```, ```Client ID```, and ```Certificate Path``` fields come from the [Microsoft 365 email setup](#microsoft-365-email-setup) steps above. \
<img src="./docs/README-Images/DialogueEntry.gif" alt="Dialogue Box" title="Dialogue Box">

Click ```Save``` and exit the popup box \
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
certifi==2026.1.4
charset-normalizer==3.4.4
cryptography==44.0.0
idna==3.11
msal==1.31.1
python-dotenv==1.2.1
requests==2.32.5
urllib3==2.6.3
Expand Down
5 changes: 4 additions & 1 deletion scripts/initial_setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ if [ ! -d ".venv" ]; then
python3 -m venv .venv
fi
source .venv/bin/activate
pip install python-dotenv
# email_sender.py runs from cron via this venv; it needs python-dotenv (config),
# msal (Microsoft 365 OAuth2 token acquisition), and cryptography (loading the
# certificate credential).
pip install python-dotenv msal cryptography

echo
echo "Getting Windows IP"
Expand Down
47 changes: 39 additions & 8 deletions src/dialogueBox.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import tkinter as tk
from tkinter import filedialog
import configparser
import dotenv
import secrets
Expand All @@ -22,7 +23,9 @@ def Save_Button():
newSendTime = inpSendTime.get()
newFreq = freqVar.get()
newFreqUnit = timeUnit.get()
newEmailPass = inpEmailPass.get()
newTenantId = inpTenantId.get().strip()
newClientId = inpClientId.get().strip()
newCertPath = inpCertPath.get().strip()
newVerkAPI = inpApiKey.get("1.0", "end").strip()
newElastPass = inpElastPass.get()
newKibPass =inpKibPass.get()
Expand All @@ -35,7 +38,9 @@ def Save_Button():
co["Email"]["EMAIL_BODY_PREFIX"] = newMessage
co["Email"]["EMAIL_SEND_TIME"] = newSendTime
co["Verkada"]["ELASTIC_UPDATE_INTERVAL"] = newFreq + newFreqUnit
dotenv.set_key(dotenv_file, "EMAIL_PASSWORD", newEmailPass)
dotenv.set_key(dotenv_file, "AZURE_TENANT_ID", newTenantId)
dotenv.set_key(dotenv_file, "AZURE_CLIENT_ID", newClientId)
dotenv.set_key(dotenv_file, "AZURE_CERT_PATH", newCertPath)
dotenv.set_key(dotenv_file, "VERKADA_API_KEY", newVerkAPI)
dotenv.set_key(dotenv_file, "ELASTIC_PASSWORD", newElastPass)
dotenv.set_key(dotenv_file,"KIBANA_SYSTEM_PASSWORD", newKibPass)
Expand All @@ -57,6 +62,15 @@ def generate_kibana_key():
inpKibEnc.delete("1.0", "end")
inpKibEnc.insert("1.0", key or "")

def browse_cert():
path = filedialog.askopenfilename(
title="Select Microsoft 365 certificate (private key + cert PEM)",
filetypes=[("PEM files", "*.pem"), ("All files", "*.*")],
)
if path:
inpCertPath.delete(0, "end")
inpCertPath.insert(0, path)

BASE_DIR = os.path.dirname(os.path.abspath(__file__))

co = configparser.ConfigParser()
Expand Down Expand Up @@ -93,9 +107,18 @@ def generate_kibana_key():
sendTimeLabel = tk.Label(root, text="Email Send Time (24HR)").grid(row=5, column=0)
inpSendTime = tk.Entry(root, width = 7)

#Email Password
emailPassLabel = tk.Label(root, text="Email Password").grid(row=6, column=0)
inpEmailPass = tk.Entry(root, width=50)
#Microsoft 365 app registration (OAuth2) -- three fields grouped in one row.
#See the "Microsoft 365 email setup" section of the README for how to obtain
#these values.
azureLabel = tk.Label(root, text="Microsoft 365 App").grid(row=6, column=0)
azureFrame = tk.Frame(root)
tk.Label(azureFrame, text="Tenant ID").grid(row=0, column=0, sticky='e')
inpTenantId = tk.Entry(azureFrame, width=40)
tk.Label(azureFrame, text="Client ID").grid(row=1, column=0, sticky='e')
inpClientId = tk.Entry(azureFrame, width=40)
tk.Label(azureFrame, text="Certificate Path").grid(row=2, column=0, sticky='e')
inpCertPath = tk.Entry(azureFrame, width=40)
certBrowseButton = tk.Button(azureFrame, text="Browse", command=browse_cert)

#ELASTIC_UPDATE_INTERVAL
freqLabel1 = tk.Label(root, text="Update Elastic every")
Expand Down Expand Up @@ -136,7 +159,11 @@ def generate_kibana_key():
inpSubject.grid(row=3, column=1, padx=5, pady=5, sticky='w')
inpMessage.grid(row=4, column=1, padx=(5,15), pady=5, sticky='w')
inpSendTime.grid(row=5, column=1, padx=5, pady=5, sticky='w')
inpEmailPass.grid(row=6, column=1, padx=5, pady=5, sticky='w')
azureFrame.grid(row=6, column=1, padx=5, pady=5, sticky='w')
inpTenantId.grid(row=0, column=1, padx=5, pady=2, sticky='w')
inpClientId.grid(row=1, column=1, padx=5, pady=2, sticky='w')
inpCertPath.grid(row=2, column=1, padx=5, pady=2, sticky='w')
certBrowseButton.grid(row=2, column=2, padx=5, pady=2, sticky='w')

#Verkada Entries
freqLabel1.grid(row=7, column=0, padx=5, pady=5)
Expand All @@ -160,7 +187,9 @@ def generate_kibana_key():
emailSubject = co.get("Email", "EMAIL_SUBJECT")
emailMessage = co.get("Email", "EMAIL_BODY_PREFIX")
emailSendTime = co.get("Email", "EMAIL_SEND_TIME")
emailPass = os.getenv("EMAIL_PASSWORD")
tenantId = os.getenv("AZURE_TENANT_ID")
clientId = os.getenv("AZURE_CLIENT_ID")
certPath = os.getenv("AZURE_CERT_PATH")
verkAPIKey = os.getenv("VERKADA_API_KEY")
elasticPass = os.getenv("ELASTIC_PASSWORD")
kibanaPass = os.getenv("KIBANA_SYSTEM_PASSWORD")
Expand All @@ -172,7 +201,9 @@ def generate_kibana_key():
inpSubject.insert(0, emailSubject)
inpMessage.insert("1.0", emailMessage)
inpSendTime.insert(0, emailSendTime)
inpEmailPass.insert(0, emailPass or "")
inpTenantId.insert(0, tenantId or "")
inpClientId.insert(0, clientId or "")
inpCertPath.insert(0, certPath or "")
inpApiKey.insert("1.0", verkAPIKey or "")
inpElastPass.insert(0, elasticPass or "")
inpKibPass.insert(0, kibanaPass or "")
Expand Down
92 changes: 78 additions & 14 deletions src/email_sender.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import base64
import logging
import os
import smtplib
import socket
import subprocess
import Utils

import msal

from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from dotenv import load_dotenv
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
Expand Down Expand Up @@ -33,21 +38,75 @@ def domain_to_server(email: str) -> str | None:
return smtp_server
return None

# Microsoft 365 has disabled Basic Authentication (username/password) for SMTP
# AUTH. Instead we authenticate with an OAuth2 access token using the SMTP
# XOAUTH2 mechanism. The token is obtained app-only (client credentials) from
# Microsoft Entra ID using an app registration authenticated with a certificate
# -- see the "Microsoft 365 email setup" section of the README for how to create
# the app, upload the certificate, and grant it the SMTP.SendAsApp permission
# scoped to the sender mailbox.

# Build the MSAL certificate credential from a PEM file that contains both the
# private key and the certificate. MSAL needs the private key plus the cert's
# SHA-1 thumbprint, which we compute here so the operator never has to copy it.
def load_client_credential() -> dict:
with open(cert_path, "rb") as f:
pem_data = f.read()
cert = x509.load_pem_x509_certificate(pem_data)
return {
"private_key": pem_data.decode(),
"thumbprint": cert.fingerprint(hashes.SHA1()).hex(),
"public_certificate": cert.public_bytes(serialization.Encoding.PEM).decode(),
}

def get_access_token() -> str:
if not (tenant_id and client_id and cert_path):
raise RuntimeError(
"Missing Microsoft 365 credentials. AZURE_TENANT_ID, AZURE_CLIENT_ID "
"and AZURE_CERT_PATH must all be set in the .env file "
"(run the setup dialogue box to configure them)."
)
if not os.path.isfile(cert_path):
raise RuntimeError(f"Certificate file not found: {cert_path}")

app = msal.ConfidentialClientApplication(
client_id,
authority=f"https://login.microsoftonline.com/{tenant_id}",
client_credential=load_client_credential(),
)
# ".default" requests the application permissions already consented to the
# app registration (i.e. SMTP.SendAsApp on Office 365 Exchange Online).
result = app.acquire_token_for_client(
scopes=["https://outlook.office365.com/.default"]
)
if "access_token" not in result:
raise RuntimeError(
"Unable to acquire Microsoft 365 access token: "
f"{result.get('error_description', result)}"
)
return result["access_token"]

def send_email():
smtp_server = domain_to_server(sender_email)
if smtp_server:
try:
with smtplib.SMTP(f"smtp.{smtp_server}", 587) as s:
s.set_debuglevel(1)
s.starttls()
s.login(sender_email, str(email_password))
s.sendmail(sender_email, recipient_email, message.as_string())
s.quit()
except Exception as e:
logging.error(f"Unable to send email: error code {e.args.index}")
exit(1)
else:
if not smtp_server:
logging.error("Unable to send email. Make sure the sender email is correct!")
return

try:
access_token = get_access_token()
# XOAUTH2 SASL initial-response string; smtplib base64-encodes it for us.
auth_string = f"user={sender_email}\1auth=Bearer {access_token}\1\1"

with smtplib.SMTP(f"smtp.{smtp_server}", 587) as s:
s.set_debuglevel(1)
s.ehlo()
s.starttls()
s.ehlo()
s.auth("XOAUTH2", lambda challenge=None: auth_string)
s.sendmail(sender_email, recipient_email, message.as_string())
except Exception as e:
logging.error(f"Unable to send email: {e}")
exit(1)

# for sending the link to Kibana through email
# IP address is likely to change due to proxies or DHCP lease termination
Expand All @@ -68,7 +127,9 @@ def get_ip():
script_path = Utils.get_project_root() / "scripts" / "get_ip.sh"
subprocess.run([script_path], check=True)

load_dotenv()
# Load .env from the project root explicitly so this works when cron runs the
# script from an arbitrary working directory.
load_dotenv(Utils.get_project_root() / ".env")
IP = os.getenv("WINDOWS_IP") or get_ip()

# read prefix only from config (no env fallback)
Expand All @@ -87,7 +148,10 @@ def get_ip():

message.attach(MIMEText(html, "html"))

email_password = os.getenv("EMAIL_PASSWORD")
# Microsoft 365 app-only (certificate credentials) OAuth2 credentials.
tenant_id = os.getenv("AZURE_TENANT_ID")
client_id = os.getenv("AZURE_CLIENT_ID")
cert_path = os.getenv("AZURE_CERT_PATH")

send_email()

Loading