328 lines
11 KiB
Markdown
328 lines
11 KiB
Markdown
# Flowers
|
|
|
|
Flowers is a small self-hosted credential vault built with Flask and MariaDB/MySQL. This version rewrites the legacy server and browser UI while intentionally preserving its on-disk data contract.
|
|
|
|
## Existing database compatibility
|
|
|
|
No data migration is required. The rewritten application continues to use:
|
|
|
|
- the `Flowers` database;
|
|
- one database account and table named `<username>_` per vault;
|
|
- the existing `organization`, `myID`, `myName`, `mySecret`, `dateCreated`, and `deleted` columns;
|
|
- the historical password transformation `(password * 10)[:43] + "="` for both the database password and Fernet key;
|
|
- Fernet ciphertext in the three protected columns;
|
|
- append-only edits, where the newest timestamp is the visible version; and
|
|
- `deleted = 1` for deactivation.
|
|
|
|
The old form-encoded `POST /app` endpoint is also retained. Its `list` and `flower` properties remain JSON strings inside the outer JSON response for existing client compatibility.
|
|
|
|
The legacy key derivation is preserved only because changing it would make existing data unreadable. It is not a modern password KDF. A future KDF upgrade requires an explicit, tested data migration rather than an in-place code change.
|
|
|
|
## What changed
|
|
|
|
- All stored values are passed to MariaDB as query parameters.
|
|
- Usernames are validated before being used as table/account identifiers.
|
|
- Database and runtime settings can be supplied through environment variables.
|
|
- Browser forms have CSRF protection.
|
|
- Database credentials are stored in server memory behind an opaque session token, not in Flask's signed browser cookie.
|
|
- Browser sessions expire after ten minutes of inactivity.
|
|
- Failed-login state is JSON rather than unsafe pickle data.
|
|
- The browser interface is responsive and no longer depends on jQuery, Pure CSS, or remote assets.
|
|
- The Flask application factory and WSGI entry point both work.
|
|
- Compatibility tests cover legacy encryption, reads, writes, listing, and the old API format.
|
|
|
|
## Requirements
|
|
|
|
- Python 3.10 or newer
|
|
- MariaDB or MySQL on the configured host
|
|
- the packages in `requirements.txt`
|
|
|
|
```bash
|
|
python3 -m venv .venv
|
|
source .venv/bin/activate
|
|
pip install -r requirements.txt
|
|
```
|
|
|
|
## Configuration
|
|
|
|
Local secrets are loaded from the git-ignored `secrets.yaml` file:
|
|
|
|
```yaml
|
|
flask:
|
|
secret_key: "a-long-random-value"
|
|
|
|
database:
|
|
admin_password: "the-provisioning-account-password"
|
|
```
|
|
|
|
Use `secrets.example.yaml` as a template. `FLOWERS_SECRETS_FILE` can point to a different file. Secret environment variables take precedence over YAML, which is useful for containers and managed deployments.
|
|
|
|
The other settings remain environment-based:
|
|
|
|
| Environment variable | Default |
|
|
| --- | --- |
|
|
| `FLOWERS_SECRETS_FILE` | `secrets.yaml` beside the application |
|
|
| `FLOWERS_SECRET_KEY` | overrides `flask.secret_key` from YAML |
|
|
| `FLOWERS_DB_HOST` | `localhost` |
|
|
| `FLOWERS_DB_PORT` | `3306` |
|
|
| `FLOWERS_DB_NAME` | `Flowers` |
|
|
| `FLOWERS_DB_ADMIN_USER` | `flower` |
|
|
| `FLOWERS_DB_ADMIN_PASSWORD` | overrides `database.admin_password` from YAML |
|
|
| `FLOWERS_URL_PREFIX` | empty |
|
|
| `FLOWERS_SESSION_MINUTES` | `10` |
|
|
| `FLOWERS_COOKIE_SECURE` | `0`; set to `1` behind HTTPS |
|
|
| `FLOWERS_ACCESS_FILE` | `/tmp/wsgi_flower_accessfile` |
|
|
| `FLOWERS_LOG_FILE` | `/tmp/wsgi_flowers.log` |
|
|
|
|
The provisioning account is needed only by the web-based `/create` flow. Existing vault reads and writes connect with their existing per-user database accounts.
|
|
|
|
## Database bootstrap for a new installation
|
|
|
|
An existing Flowers database should be left untouched. For a new installation, create the database and provisioning user, replacing the example password:
|
|
|
|
```sql
|
|
CREATE DATABASE Flowers;
|
|
CREATE USER 'flower'@'localhost' IDENTIFIED BY 'replace-this';
|
|
GRANT CREATE, SELECT, UPDATE, INSERT ON Flowers.*
|
|
TO 'flower'@'localhost' WITH GRANT OPTION;
|
|
GRANT CREATE USER, RELOAD ON *.* TO 'flower'@'localhost';
|
|
FLUSH PRIVILEGES;
|
|
```
|
|
|
|
Put the same value in `database.admin_password` in `secrets.yaml` before starting Flowers.
|
|
|
|
## Run locally
|
|
|
|
Development:
|
|
|
|
```bash
|
|
python flowers.py
|
|
```
|
|
|
|
To exercise the production WSGI server locally:
|
|
|
|
```bash
|
|
gunicorn --workers 1 --threads 6 --bind 127.0.0.1:5012 'flowers:create_app()'
|
|
```
|
|
|
|
Flowers keeps authenticated database credentials in process memory. Always use exactly
|
|
one Gunicorn worker: users would otherwise appear to be logged out whenever a request
|
|
reached a different worker. Threads provide concurrency within that worker. Restarting
|
|
Gunicorn intentionally expires all active Flowers sessions.
|
|
|
|
## Deploy with Nginx, Gunicorn, and systemd
|
|
|
|
The following example installs Flowers in `/opt/flowers`, runs it as an unprivileged
|
|
`flowers` user, binds Gunicorn only to loopback, and exposes it through Nginx over
|
|
HTTPS. Adapt the paths, hostname, user, and certificate locations for your server.
|
|
|
|
### 1. Install the application
|
|
|
|
Install Python, its virtual-environment support, MariaDB/MySQL client libraries, Nginx,
|
|
and your distribution's certificate tooling. Then copy or clone the repository and
|
|
create the service account and virtual environment:
|
|
|
|
```bash
|
|
sudo useradd --system --user-group --home /opt/flowers \
|
|
--shell /usr/sbin/nologin flowers
|
|
sudo install -d -o flowers -g flowers /opt/flowers
|
|
sudo -u flowers git clone https://example.invalid/flowers.git /opt/flowers
|
|
sudo -u flowers python3 -m venv /opt/flowers/.venv
|
|
sudo -u flowers /opt/flowers/.venv/bin/pip install \
|
|
-r /opt/flowers/requirements.txt
|
|
```
|
|
|
|
Replace the example clone URL with this repository's URL. For an artifact-based
|
|
deployment, copy the release into `/opt/flowers` instead and make it owned by
|
|
`flowers:flowers`.
|
|
|
|
### 2. Configure secrets and the environment
|
|
|
|
Keep deployment secrets outside the repository:
|
|
|
|
```bash
|
|
sudo install -d -m 0750 -o root -g flowers /etc/flowers
|
|
sudo install -m 0640 -o root -g flowers \
|
|
/opt/flowers/secrets.example.yaml /etc/flowers/secrets.yaml
|
|
sudoedit /etc/flowers/secrets.yaml
|
|
sudoedit /etc/flowers/flowers.env
|
|
sudo chown root:flowers /etc/flowers/flowers.env
|
|
sudo chmod 0640 /etc/flowers/flowers.env
|
|
```
|
|
|
|
Use this as `/etc/flowers/flowers.env`:
|
|
|
|
```dotenv
|
|
FLOWERS_SECRETS_FILE=/etc/flowers/secrets.yaml
|
|
FLOWERS_DB_HOST=127.0.0.1
|
|
FLOWERS_DB_PORT=3306
|
|
FLOWERS_DB_NAME=Flowers
|
|
FLOWERS_DB_ADMIN_USER=flower
|
|
FLOWERS_COOKIE_SECURE=1
|
|
FLOWERS_ACCESS_FILE=/var/lib/flowers/access
|
|
FLOWERS_LOG_FILE=/var/log/flowers/flowers.log
|
|
```
|
|
|
|
Create the writable locations referenced above:
|
|
|
|
```bash
|
|
sudo install -d -m 0750 -o flowers -g flowers /var/lib/flowers
|
|
sudo install -d -m 0750 -o flowers -g flowers /var/log/flowers
|
|
```
|
|
|
|
Set a stable, random `flask.secret_key` and the database provisioning password in
|
|
`/etc/flowers/secrets.yaml`. Do not generate a new Flask secret on each deployment,
|
|
because changing it invalidates every browser session. If the database is on another
|
|
host, adjust `FLOWERS_DB_HOST` and ensure its grants and firewall allow the Flowers
|
|
server.
|
|
|
|
### 3. Create the systemd service
|
|
|
|
Create `/etc/systemd/system/flowers.service`:
|
|
|
|
```ini
|
|
[Unit]
|
|
Description=Flowers credential vault
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=flowers
|
|
Group=flowers
|
|
WorkingDirectory=/opt/flowers
|
|
EnvironmentFile=/etc/flowers/flowers.env
|
|
ExecStart=/opt/flowers/.venv/bin/gunicorn --workers 1 --threads 6 --bind 127.0.0.1:5012 --access-logfile - --error-logfile - "flowers:create_app()"
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
TimeoutStopSec=30
|
|
PrivateTmp=true
|
|
NoNewPrivileges=true
|
|
UMask=0077
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
Enable the service and confirm that Gunicorn answers locally:
|
|
|
|
```bash
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable --now flowers
|
|
sudo systemctl status flowers
|
|
curl --fail --head http://127.0.0.1:5012/
|
|
```
|
|
|
|
Service and access logs are available through `journalctl -u flowers`. The application
|
|
also writes its own log to the configured `FLOWERS_LOG_FILE`.
|
|
|
|
### 4. Configure Nginx
|
|
|
|
Create `/etc/nginx/sites-available/flowers` (or the equivalent include path on your
|
|
distribution):
|
|
|
|
```nginx
|
|
server {
|
|
listen 80;
|
|
listen [::]:80;
|
|
server_name flowers.example.com;
|
|
return 301 https://$host$request_uri;
|
|
}
|
|
|
|
server {
|
|
listen 443 ssl http2;
|
|
listen [::]:443 ssl http2;
|
|
server_name flowers.example.com;
|
|
|
|
ssl_certificate /etc/letsencrypt/live/flowers.example.com/fullchain.pem;
|
|
ssl_certificate_key /etc/letsencrypt/live/flowers.example.com/privkey.pem;
|
|
|
|
client_max_body_size 64k;
|
|
|
|
location / {
|
|
proxy_pass http://127.0.0.1:5012;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header Connection "";
|
|
proxy_read_timeout 30s;
|
|
}
|
|
}
|
|
```
|
|
|
|
Obtain the certificate before enabling the TLS server, or first use your ACME client's
|
|
HTTP-only bootstrap configuration. Then enable and validate the site:
|
|
|
|
```bash
|
|
sudo ln -s /etc/nginx/sites-available/flowers /etc/nginx/sites-enabled/flowers
|
|
sudo nginx -t
|
|
sudo systemctl reload nginx
|
|
curl --fail --head https://flowers.example.com/
|
|
```
|
|
|
|
Keep port `5012` closed in the host firewall; only ports 80 and 443 need to be public.
|
|
If another reverse proxy sits in front of Nginx, configure Nginx to accept client IP
|
|
headers only from that proxy rather than from arbitrary clients.
|
|
|
|
### Deploy under a URL prefix
|
|
|
|
To publish Flowers at `https://example.com/flowers/`, add this setting to
|
|
`/etc/flowers/flowers.env`:
|
|
|
|
```dotenv
|
|
FLOWERS_URL_PREFIX=/flowers
|
|
```
|
|
|
|
Use these Nginx locations without a trailing path on `proxy_pass`, so the `/flowers`
|
|
prefix reaches Flask unchanged:
|
|
|
|
```nginx
|
|
location = /flowers {
|
|
return 301 /flowers/;
|
|
}
|
|
|
|
location /flowers/ {
|
|
proxy_pass http://127.0.0.1:5012;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header Connection "";
|
|
}
|
|
```
|
|
|
|
After changing the environment or application code, restart Gunicorn with
|
|
`sudo systemctl restart flowers`. After changing Nginx, run `sudo nginx -t` before
|
|
reloading it.
|
|
|
|
## Tests
|
|
|
|
The tests do not need a live database; they use the actual legacy Fernet implementation with a MariaDB-compatible fake connection.
|
|
|
|
```bash
|
|
pip install -r requirements-dev.txt
|
|
python -m pytest
|
|
```
|
|
|
|
They can also run using only the standard library test runner:
|
|
|
|
```bash
|
|
python -m unittest discover -s tests -v
|
|
```
|
|
|
|
## Remaining security constraints
|
|
|
|
The rewrite removes the immediately exploitable SQL interpolation and browser-cookie credential storage, but Flowers remains a small personal vault with a legacy storage design:
|
|
|
|
- the historical password-derived Fernet key is intentionally still weak;
|
|
- the web process still has provisioning privileges if `/create` is enabled;
|
|
- the in-memory credential store is suitable for one application process, not a multi-process cluster; and
|
|
- rate-limit state is local to one host.
|
|
|
|
Use a strong unique password made only of Base64-compatible characters, bind Gunicorn
|
|
to localhost, and expose it only through an authenticated HTTPS reverse proxy. Set
|
|
stable provisioning and Flask secrets before deployment.
|