Initial idea
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
debian/build-daemon
|
||||
debian/cargo-home
|
||||
debian/.debhelper
|
||||
debian/debhelper-build-stamp
|
||||
debian/age-verification-services.substvars
|
||||
debian/files
|
||||
debian/age-verification-services/
|
||||
debian/tmp/
|
||||
sync-client/target/
|
||||
*.deb
|
||||
*.buildinfo
|
||||
*.changes
|
||||
@@ -0,0 +1,224 @@
|
||||
# age-verification
|
||||
|
||||
Implementation of `org.freedesktop.AgeVerification1` — a D-Bus interface for
|
||||
California SB-976-style age bracket compliance on Linux, with an optional
|
||||
privacy-preserving account sync backend.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Linux OS (Kicksecure / Whonix / Debian / etc.) │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ age-verification-daemon (C) │ system D-Bus │
|
||||
│ │ /var/lib/age-verification/ │ org.freedesktop. │
|
||||
│ │ ages.conf (root:root 0600) │ AgeVerification1 │
|
||||
│ └────────────────┬─────────────────┘ │
|
||||
│ │ D-Bus GetAgeBracket │
|
||||
│ ┌────────────────▼─────────────────┐ │
|
||||
│ │ age-verification-sync (Rust) │──────────────────────► │
|
||||
│ │ Layered encryption: │ HTTPS (TLS) │
|
||||
│ │ 1. TLS transport │ POST /api/v1/ │
|
||||
│ │ 2. RSA-OAEP server pubkey │ age-bracket │
|
||||
│ │ 3. Ed25519 device sig │ │
|
||||
│ │ 4. AES-256-GCM local keystore │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ age-verification-server (Node) │
|
||||
│ SQLite: stores bracket (1-4) │
|
||||
│ ONLY — no raw age/dob stored │
|
||||
│ │
|
||||
│ GET /api/v1/query/bracket │◄── OS services /
|
||||
│ (returns only bracket int) │ app stores
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Age brackets
|
||||
|
||||
| Bracket | Meaning |
|
||||
|---------|----------------------|
|
||||
| 1 | Under 13 |
|
||||
| 2 | 13 – 15 (inclusive) |
|
||||
| 3 | 16 – 17 (inclusive) |
|
||||
| 4 | 18 or older |
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### 1. `daemon/` — C D-Bus daemon
|
||||
|
||||
Implements `org.freedesktop.AgeVerification1` on the **system bus**.
|
||||
Runs as root. Stores age brackets (not raw ages) in `/var/lib/age-verification/ages.conf`.
|
||||
|
||||
**Build:**
|
||||
```bash
|
||||
cd daemon
|
||||
cmake -B build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build
|
||||
sudo cmake --install build
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now age-verification-daemon
|
||||
```
|
||||
|
||||
**Test via dbus-send:**
|
||||
```bash
|
||||
# Set age
|
||||
sudo dbus-send --system --print-reply \
|
||||
--dest=org.freedesktop.AgeVerification1 \
|
||||
/org/freedesktop/AgeVerification1 \
|
||||
org.freedesktop.AgeVerification1.SetAge \
|
||||
string:youruser uint32:25
|
||||
|
||||
# Set date of birth
|
||||
sudo dbus-send --system --print-reply \
|
||||
--dest=org.freedesktop.AgeVerification1 \
|
||||
/org/freedesktop/AgeVerification1 \
|
||||
org.freedesktop.AgeVerification1.SetDateOfBirth \
|
||||
string:youruser string:2000-06-15
|
||||
|
||||
# Get bracket
|
||||
dbus-send --system --print-reply \
|
||||
--dest=org.freedesktop.AgeVerification1 \
|
||||
/org/freedesktop/AgeVerification1 \
|
||||
org.freedesktop.AgeVerification1.GetAgeBracket \
|
||||
string:youruser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. `sync-client/` — Rust sync client
|
||||
|
||||
Reads the bracket from the local daemon and uploads it to your account server
|
||||
using layered encryption:
|
||||
|
||||
1. **TLS** — all traffic over HTTPS via rustls
|
||||
2. **RSA-4096 OAEP-SHA256** — bracket payload encrypted with the server's public key
|
||||
3. **Ed25519 device keypair** — payload signed to prove device identity
|
||||
4. **AES-256-GCM + PBKDF2** — local keystore encrypted with account passphrase
|
||||
|
||||
**Build:**
|
||||
```bash
|
||||
cd sync-client
|
||||
cargo build --release
|
||||
# Binary at target/release/age-verification-sync
|
||||
```
|
||||
|
||||
**First-time device registration** (do this once after account creation):
|
||||
```bash
|
||||
# Register device with server — you'll need to extract the Ed25519 pubkey
|
||||
# from the keystore or add a `--print-pubkey` subcommand for this.
|
||||
# See TODO in src/main.rs for a planned 'register-device' subcommand.
|
||||
```
|
||||
|
||||
**Sync:**
|
||||
```bash
|
||||
age-verification-sync \
|
||||
--user alice \
|
||||
--server https://accounts.example.com \
|
||||
--token "$YOUR_ACCOUNT_JWT"
|
||||
```
|
||||
|
||||
**Deploy as a systemd timer** (syncs on login or periodically):
|
||||
```ini
|
||||
# /etc/systemd/user/age-verification-sync.timer
|
||||
[Unit]
|
||||
Description=Sync age bracket
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30s
|
||||
OnUnitActiveSec=24h
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. `server/` — Node.js account server
|
||||
|
||||
Express + SQLite backend. Stores **only the bracket integer** per account.
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
npm run keygen # generates keys/server-private.pem + server-public.pem
|
||||
cp .env.example .env # fill in JWT_SECRET
|
||||
mkdir -p data
|
||||
npm start
|
||||
```
|
||||
|
||||
**Distribute the public key to OS installs:**
|
||||
```bash
|
||||
scp keys/server-public.pem \
|
||||
root@client:/etc/age-verification/server-pubkey.pem
|
||||
```
|
||||
|
||||
**API endpoints:**
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/v1/auth/register` | — | Create account |
|
||||
| POST | `/api/v1/auth/login` | — | Get JWT |
|
||||
| POST | `/api/v1/auth/register-device` | JWT | Register device Ed25519 pubkey |
|
||||
| POST | `/api/v1/age-bracket` | JWT | Upload encrypted bracket from sync client |
|
||||
| GET | `/api/v1/query/bracket` | JWT | Get bracket for this account (returns `{bracket: N}` only) |
|
||||
|
||||
**Production deployment:**
|
||||
- Put nginx in front (TLS termination, `proxy_pass http://127.0.0.1:3000`)
|
||||
- Use a process manager: `pm2 start src/index.js --name age-verif`
|
||||
- Back up `data/ageverif.db` and `keys/server-private.pem` (separately, securely)
|
||||
|
||||
---
|
||||
|
||||
## Privacy properties
|
||||
|
||||
| Property | How it's achieved |
|
||||
|----------|------------------|
|
||||
| Minimum data on disk | Daemon stores bracket (1–4), not raw age |
|
||||
| Minimum data on server | Server stores bracket (1–4), not raw age |
|
||||
| Local file not world-readable | `/var/lib/age-verification/ages.conf` is root:root 0600 |
|
||||
| Local keystore encrypted | AES-256-GCM keyed from PBKDF2(account passphrase, salt) |
|
||||
| Transport encrypted | TLS (reqwest/rustls, HTTPS only) |
|
||||
| Server can't be MITM'd | Payload also RSA-OAEP encrypted with server pubkey |
|
||||
| Device identity verified | Ed25519 signature on every sync payload |
|
||||
| Server returns minimum | `/query/bracket` returns `{bracket: N}` only |
|
||||
|
||||
---
|
||||
|
||||
## D-Bus interface reference
|
||||
|
||||
```xml
|
||||
<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
|
||||
<node name="/org/freedesktop/AgeVerification1">
|
||||
<interface name="org.freedesktop.AgeVerification1">
|
||||
<method name="SetAge">
|
||||
<arg type="s" name="User" direction="in"/>
|
||||
<arg type="u" name="YearsOfAge" direction="in"/>
|
||||
</method>
|
||||
<method name="SetDateOfBirth">
|
||||
<arg type="s" name="User" direction="in"/>
|
||||
<arg type="s" name="Date" direction="in"/>
|
||||
</method>
|
||||
<method name="GetAgeBracket">
|
||||
<arg type="s" name="User" direction="in"/>
|
||||
<arg type="u" name="AgeBracket" direction="out"/>
|
||||
</method>
|
||||
</interface>
|
||||
</node>
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `org.freedesktop.AgeVerification1.Error.NoSuchUser`
|
||||
- `org.freedesktop.AgeVerification1.Error.PermissionDenied`
|
||||
- `org.freedesktop.AgeVerification1.Error.InvalidDate`
|
||||
- `org.freedesktop.AgeVerification1.Error.AgeUndefined`
|
||||
- `org.freedesktop.AgeVerification1.Error.Internal`
|
||||
@@ -0,0 +1,24 @@
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
project(age-verification-daemon C)
|
||||
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(DBUS REQUIRED dbus-1)
|
||||
pkg_check_modules(SYSTEMD systemd)
|
||||
|
||||
add_executable(age-verification-daemon age-verification-daemon.c)
|
||||
target_include_directories(age-verification-daemon PRIVATE ${DBUS_INCLUDE_DIRS})
|
||||
target_link_libraries(age-verification-daemon ${DBUS_LIBRARIES})
|
||||
target_compile_options(age-verification-daemon PRIVATE ${DBUS_CFLAGS_OTHER} -Wall -Wextra -O2)
|
||||
|
||||
if(SYSTEMD_FOUND)
|
||||
target_compile_definitions(age-verification-daemon PRIVATE HAVE_SYSTEMD)
|
||||
target_include_directories(age-verification-daemon PRIVATE ${SYSTEMD_INCLUDE_DIRS})
|
||||
target_link_libraries(age-verification-daemon ${SYSTEMD_LIBRARIES} -lsystemd)
|
||||
target_compile_options(age-verification-daemon PRIVATE ${SYSTEMD_CFLAGS_OTHER})
|
||||
endif()
|
||||
|
||||
install(TARGETS age-verification-daemon DESTINATION lib/age-verification)
|
||||
install(FILES age-verification-daemon.service
|
||||
DESTINATION lib/systemd/system)
|
||||
install(FILES org.freedesktop.AgeVerification1.conf
|
||||
DESTINATION share/dbus-1/system.d)
|
||||
@@ -0,0 +1,358 @@
|
||||
/*
|
||||
* age-verification-daemon.c
|
||||
* Implements org.freedesktop.AgeVerification1 on the system D-Bus.
|
||||
* Runs as root. Stores age data in /var/lib/age-verification/ (root-owned, 0600).
|
||||
*
|
||||
* Dependencies: libdbus-1, libsystemd (optional, for sd_notify)
|
||||
* Build: gcc -o age-verification-daemon age-verification-daemon.c \
|
||||
* $(pkg-config --cflags --libs dbus-1) -lsystemd
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <pwd.h>
|
||||
#include <limits.h>
|
||||
#include <time.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
#include <dbus/dbus.h>
|
||||
|
||||
/* Optional systemd notify support */
|
||||
#ifdef HAVE_SYSTEMD
|
||||
#include <systemd/sd-daemon.h>
|
||||
#define NOTIFY_READY() sd_notify(0, "READY=1")
|
||||
#else
|
||||
#define NOTIFY_READY() do {} while(0)
|
||||
#endif
|
||||
|
||||
#define STORAGE_DIR "/var/lib/age-verification"
|
||||
#define STORAGE_FILE STORAGE_DIR "/ages.conf"
|
||||
#define DBUS_SERVICE "org.freedesktop.AgeVerification1"
|
||||
#define DBUS_IFACE "org.freedesktop.AgeVerification1"
|
||||
#define DBUS_PATH "/org/freedesktop/AgeVerification1"
|
||||
|
||||
#define ERR_NO_USER "org.freedesktop.AgeVerification1.Error.NoSuchUser"
|
||||
#define ERR_PERM_DENIED "org.freedesktop.AgeVerification1.Error.PermissionDenied"
|
||||
#define ERR_INVALID_DATE "org.freedesktop.AgeVerification1.Error.InvalidDate"
|
||||
#define ERR_AGE_UNDEF "org.freedesktop.AgeVerification1.Error.AgeUndefined"
|
||||
#define ERR_INTERNAL "org.freedesktop.AgeVerification1.Error.Internal"
|
||||
|
||||
/* Age brackets as per California law */
|
||||
typedef enum {
|
||||
BRACKET_UNDER_13 = 1,
|
||||
BRACKET_13_TO_15 = 2,
|
||||
BRACKET_16_TO_17 = 3,
|
||||
BRACKET_ADULT = 4
|
||||
} AgeBracket;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Utility: get UID_MIN / UID_MAX from /etc/login.defs
|
||||
* --------------------------------------------------------------------------- */
|
||||
static void get_uid_range(uid_t *uid_min, uid_t *uid_max) {
|
||||
*uid_min = 1000;
|
||||
*uid_max = 60000;
|
||||
FILE *f = fopen("/etc/login.defs", "r");
|
||||
if (!f) return;
|
||||
char line[256];
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
unsigned long val;
|
||||
if (sscanf(line, "UID_MIN %lu", &val) == 1) *uid_min = (uid_t)val;
|
||||
if (sscanf(line, "UID_MAX %lu", &val) == 1) *uid_max = (uid_t)val;
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Utility: validate that 'username' is a real, non-system UNIX user
|
||||
* --------------------------------------------------------------------------- */
|
||||
static int validate_user(const char *username, uid_t *out_uid) {
|
||||
uid_t uid_min, uid_max;
|
||||
get_uid_range(&uid_min, &uid_max);
|
||||
errno = 0;
|
||||
struct passwd *pw = getpwnam(username);
|
||||
if (!pw) return 0;
|
||||
if (pw->pw_uid < uid_min || pw->pw_uid > uid_max) return 0;
|
||||
if (out_uid) *out_uid = pw->pw_uid;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Utility: get the UID of the D-Bus caller
|
||||
* --------------------------------------------------------------------------- */
|
||||
static uid_t get_caller_uid(DBusConnection *conn, DBusMessage *msg) {
|
||||
const char *sender = dbus_message_get_sender(msg);
|
||||
if (!sender) return (uid_t)-1;
|
||||
DBusError err;
|
||||
dbus_error_init(&err);
|
||||
unsigned long uid = dbus_bus_get_unix_user(conn, sender, &err);
|
||||
if (dbus_error_is_set(&err)) {
|
||||
dbus_error_free(&err);
|
||||
return (uid_t)-1;
|
||||
}
|
||||
return (uid_t)uid;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Storage: read bracket for a username. Returns 0 if not found.
|
||||
* File format: one "username=bracket" per line.
|
||||
* --------------------------------------------------------------------------- */
|
||||
static int read_bracket(const char *username) {
|
||||
FILE *f = fopen(STORAGE_FILE, "r");
|
||||
if (!f) return 0;
|
||||
char line[512];
|
||||
int found = 0;
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
/* strip newline */
|
||||
line[strcspn(line, "\n")] = 0;
|
||||
char *eq = strchr(line, '=');
|
||||
if (!eq) continue;
|
||||
*eq = 0;
|
||||
if (strcmp(line, username) == 0) {
|
||||
found = atoi(eq + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return found;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Storage: write or update bracket for a username
|
||||
* --------------------------------------------------------------------------- */
|
||||
static int write_bracket(const char *username, int bracket) {
|
||||
/* Ensure storage dir exists with correct perms */
|
||||
struct stat st;
|
||||
if (stat(STORAGE_DIR, &st) != 0) {
|
||||
if (mkdir(STORAGE_DIR, 0700) != 0) return 0;
|
||||
}
|
||||
|
||||
/* Read existing entries, skip the one we're updating */
|
||||
char tmpfile[] = STORAGE_FILE ".tmp.XXXXXX";
|
||||
int tmpfd = mkstemp(tmpfile);
|
||||
if (tmpfd < 0) return 0;
|
||||
fchmod(tmpfd, 0600);
|
||||
FILE *out = fdopen(tmpfd, "w");
|
||||
if (!out) { close(tmpfd); return 0; }
|
||||
|
||||
FILE *in = fopen(STORAGE_FILE, "r");
|
||||
if (in) {
|
||||
char line[512];
|
||||
while (fgets(line, sizeof(line), in)) {
|
||||
char copy[512];
|
||||
strncpy(copy, line, sizeof(copy));
|
||||
copy[strcspn(copy, "\n")] = 0;
|
||||
char *eq = strchr(copy, '=');
|
||||
if (eq) { *eq = 0; }
|
||||
if (!eq || strcmp(copy, username) != 0) {
|
||||
fputs(line, out);
|
||||
}
|
||||
}
|
||||
fclose(in);
|
||||
}
|
||||
|
||||
fprintf(out, "%s=%d\n", username, bracket);
|
||||
fclose(out);
|
||||
rename(tmpfile, STORAGE_FILE);
|
||||
chmod(STORAGE_FILE, 0600);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Age bracket calculation
|
||||
* --------------------------------------------------------------------------- */
|
||||
static AgeBracket bracket_from_years(unsigned int years) {
|
||||
if (years < 13) return BRACKET_UNDER_13;
|
||||
if (years < 16) return BRACKET_13_TO_15;
|
||||
if (years < 18) return BRACKET_16_TO_17;
|
||||
return BRACKET_ADULT;
|
||||
}
|
||||
|
||||
static AgeBracket bracket_from_dob(const char *iso_date) {
|
||||
/* Parse YYYY-MM-DD */
|
||||
int y, m, d;
|
||||
if (sscanf(iso_date, "%d-%d-%d", &y, &m, &d) != 3) return 0;
|
||||
if (m < 1 || m > 12 || d < 1 || d > 31) return 0;
|
||||
|
||||
time_t now = time(NULL);
|
||||
struct tm *t = gmtime(&now);
|
||||
int today_y = t->tm_year + 1900;
|
||||
int today_m = t->tm_mon + 1;
|
||||
int today_d = t->tm_mday;
|
||||
|
||||
int age = today_y - y;
|
||||
if (today_m < m || (today_m == m && today_d < d)) age--;
|
||||
if (age < 0) return 0;
|
||||
return bracket_from_years((unsigned int)age);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* D-Bus method: SetAge
|
||||
* --------------------------------------------------------------------------- */
|
||||
static DBusMessage *handle_set_age(DBusConnection *conn, DBusMessage *msg) {
|
||||
const char *username;
|
||||
dbus_uint32_t years;
|
||||
DBusError err;
|
||||
dbus_error_init(&err);
|
||||
|
||||
if (!dbus_message_get_args(msg, &err,
|
||||
DBUS_TYPE_STRING, &username,
|
||||
DBUS_TYPE_UINT32, &years,
|
||||
DBUS_TYPE_INVALID)) {
|
||||
DBusMessage *r = dbus_message_new_error(msg, DBUS_ERROR_INVALID_ARGS, err.message);
|
||||
dbus_error_free(&err);
|
||||
return r;
|
||||
}
|
||||
|
||||
uid_t target_uid;
|
||||
if (!validate_user(username, &target_uid))
|
||||
return dbus_message_new_error(msg, ERR_NO_USER, "No such non-system user");
|
||||
|
||||
uid_t caller_uid = get_caller_uid(conn, msg);
|
||||
if (caller_uid != 0 && caller_uid != target_uid)
|
||||
return dbus_message_new_error(msg, ERR_PERM_DENIED, "Permission denied");
|
||||
|
||||
AgeBracket bracket = bracket_from_years(years);
|
||||
if (!write_bracket(username, (int)bracket))
|
||||
return dbus_message_new_error(msg, ERR_INTERNAL, "Failed to write storage");
|
||||
|
||||
return dbus_message_new_method_return(msg);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* D-Bus method: SetDateOfBirth
|
||||
* --------------------------------------------------------------------------- */
|
||||
static DBusMessage *handle_set_dob(DBusConnection *conn, DBusMessage *msg) {
|
||||
const char *username, *date;
|
||||
DBusError err;
|
||||
dbus_error_init(&err);
|
||||
|
||||
if (!dbus_message_get_args(msg, &err,
|
||||
DBUS_TYPE_STRING, &username,
|
||||
DBUS_TYPE_STRING, &date,
|
||||
DBUS_TYPE_INVALID)) {
|
||||
DBusMessage *r = dbus_message_new_error(msg, DBUS_ERROR_INVALID_ARGS, err.message);
|
||||
dbus_error_free(&err);
|
||||
return r;
|
||||
}
|
||||
|
||||
uid_t target_uid;
|
||||
if (!validate_user(username, &target_uid))
|
||||
return dbus_message_new_error(msg, ERR_NO_USER, "No such non-system user");
|
||||
|
||||
uid_t caller_uid = get_caller_uid(conn, msg);
|
||||
if (caller_uid != 0 && caller_uid != target_uid)
|
||||
return dbus_message_new_error(msg, ERR_PERM_DENIED, "Permission denied");
|
||||
|
||||
AgeBracket bracket = bracket_from_dob(date);
|
||||
if (bracket == 0)
|
||||
return dbus_message_new_error(msg, ERR_INVALID_DATE, "Invalid ISO8601 date");
|
||||
|
||||
if (!write_bracket(username, (int)bracket))
|
||||
return dbus_message_new_error(msg, ERR_INTERNAL, "Failed to write storage");
|
||||
|
||||
return dbus_message_new_method_return(msg);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* D-Bus method: GetAgeBracket
|
||||
* --------------------------------------------------------------------------- */
|
||||
static DBusMessage *handle_get_bracket(DBusConnection *conn, DBusMessage *msg) {
|
||||
const char *username;
|
||||
DBusError err;
|
||||
dbus_error_init(&err);
|
||||
|
||||
if (!dbus_message_get_args(msg, &err,
|
||||
DBUS_TYPE_STRING, &username,
|
||||
DBUS_TYPE_INVALID)) {
|
||||
DBusMessage *r = dbus_message_new_error(msg, DBUS_ERROR_INVALID_ARGS, err.message);
|
||||
dbus_error_free(&err);
|
||||
return r;
|
||||
}
|
||||
|
||||
uid_t target_uid;
|
||||
if (!validate_user(username, &target_uid))
|
||||
return dbus_message_new_error(msg, ERR_NO_USER, "No such non-system user");
|
||||
|
||||
uid_t caller_uid = get_caller_uid(conn, msg);
|
||||
if (caller_uid != 0 && caller_uid != target_uid)
|
||||
return dbus_message_new_error(msg, ERR_PERM_DENIED, "Permission denied");
|
||||
|
||||
int bracket = read_bracket(username);
|
||||
if (bracket == 0)
|
||||
return dbus_message_new_error(msg, ERR_AGE_UNDEF, "No age configured for user");
|
||||
|
||||
DBusMessage *reply = dbus_message_new_method_return(msg);
|
||||
dbus_uint32_t val = (dbus_uint32_t)bracket;
|
||||
dbus_message_append_args(reply, DBUS_TYPE_UINT32, &val, DBUS_TYPE_INVALID);
|
||||
return reply;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* D-Bus message dispatch
|
||||
* --------------------------------------------------------------------------- */
|
||||
static DBusHandlerResult message_handler(DBusConnection *conn,
|
||||
DBusMessage *msg, void *data) {
|
||||
(void)data;
|
||||
|
||||
if (dbus_message_get_type(msg) != DBUS_MESSAGE_TYPE_METHOD_CALL)
|
||||
return DBUS_HANDLER_RESULT_NOT_YET_HANDLED;
|
||||
|
||||
if (!dbus_message_has_interface(msg, DBUS_IFACE))
|
||||
return DBUS_HANDLER_RESULT_NOT_YET_HANDLED;
|
||||
|
||||
DBusMessage *reply = NULL;
|
||||
const char *member = dbus_message_get_member(msg);
|
||||
|
||||
if (strcmp(member, "SetAge") == 0) reply = handle_set_age(conn, msg);
|
||||
else if (strcmp(member, "SetDateOfBirth") == 0) reply = handle_set_dob(conn, msg);
|
||||
else if (strcmp(member, "GetAgeBracket") == 0) reply = handle_get_bracket(conn, msg);
|
||||
else return DBUS_HANDLER_RESULT_NOT_YET_HANDLED;
|
||||
|
||||
if (reply) {
|
||||
dbus_connection_send(conn, reply, NULL);
|
||||
dbus_message_unref(reply);
|
||||
}
|
||||
return DBUS_HANDLER_RESULT_HANDLED;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Main
|
||||
* --------------------------------------------------------------------------- */
|
||||
int main(void) {
|
||||
DBusError err;
|
||||
dbus_error_init(&err);
|
||||
|
||||
DBusConnection *conn = dbus_bus_get(DBUS_BUS_SYSTEM, &err);
|
||||
if (dbus_error_is_set(&err)) {
|
||||
fprintf(stderr, "D-Bus connection error: %s\n", err.message);
|
||||
dbus_error_free(&err);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int ret = dbus_bus_request_name(conn, DBUS_SERVICE,
|
||||
DBUS_NAME_FLAG_REPLACE_EXISTING, &err);
|
||||
if (dbus_error_is_set(&err)) {
|
||||
fprintf(stderr, "Name request error: %s\n", err.message);
|
||||
dbus_error_free(&err);
|
||||
return 1;
|
||||
}
|
||||
if (ret != DBUS_REQUEST_NAME_REPLY_PRIMARY_OWNER) {
|
||||
fprintf(stderr, "Not primary owner of %s\n", DBUS_SERVICE);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static const DBusObjectPathVTable vtable = {
|
||||
.message_function = message_handler
|
||||
};
|
||||
dbus_connection_register_object_path(conn, DBUS_PATH, &vtable, NULL);
|
||||
|
||||
NOTIFY_READY();
|
||||
fprintf(stdout, "age-verification-daemon running on system bus\n");
|
||||
|
||||
while (dbus_connection_read_write_dispatch(conn, -1));
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
[Unit]
|
||||
Description=Age Verification D-Bus Daemon (org.freedesktop.AgeVerification1)
|
||||
Documentation=https://github.com/your-org/age-verification
|
||||
After=dbus.service
|
||||
Requires=dbus.service
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
ExecStart=/usr/lib/age-verification/age-verification-daemon
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Security hardening
|
||||
User=root
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/var/lib/age-verification
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
RestrictNamespaces=yes
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallErrorNumber=EPERM
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE busconfig PUBLIC
|
||||
"-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
|
||||
<busconfig>
|
||||
<!-- Allow the daemon (running as root) to own the service name -->
|
||||
<policy user="root">
|
||||
<allow own="org.freedesktop.AgeVerification1"/>
|
||||
</policy>
|
||||
|
||||
<!-- Allow any user to call GetAgeBracket for their own account -->
|
||||
<policy context="default">
|
||||
<allow send_destination="org.freedesktop.AgeVerification1"
|
||||
send_interface="org.freedesktop.AgeVerification1"
|
||||
send_member="GetAgeBracket"/>
|
||||
<allow send_destination="org.freedesktop.AgeVerification1"
|
||||
send_interface="org.freedesktop.AgeVerification1"
|
||||
send_member="SetAge"/>
|
||||
<allow send_destination="org.freedesktop.AgeVerification1"
|
||||
send_interface="org.freedesktop.AgeVerification1"
|
||||
send_member="SetDateOfBirth"/>
|
||||
</policy>
|
||||
</busconfig>
|
||||
Vendored
+78
@@ -0,0 +1,78 @@
|
||||
.TH AGE-VERIFICATION-DAEMON 8 "2026-03-10" "age-verification-services 0.1.0"
|
||||
.SH NAME
|
||||
age-verification-daemon \- D-Bus daemon implementing org.freedesktop.AgeVerification1
|
||||
.SH SYNOPSIS
|
||||
.B age-verification-daemon
|
||||
.SH DESCRIPTION
|
||||
.B age-verification-daemon
|
||||
is a system D-Bus service that implements the
|
||||
.B org.freedesktop.AgeVerification1
|
||||
interface, providing age bracket data to application stores and OS-level
|
||||
services in compliance with California SB-976 and similar legislation.
|
||||
.PP
|
||||
The daemon runs as root and stores age bracket data in
|
||||
.I /var/lib/age-verification/ages.conf
|
||||
(owned by root, mode 0600). Only the age bracket integer (1\(en4) is
|
||||
stored, never a raw age value or date of birth.
|
||||
.SH AGE BRACKETS
|
||||
.TS
|
||||
l l.
|
||||
Bracket Meaning
|
||||
1 Under 13 years old
|
||||
2 13 to 15 years old (inclusive)
|
||||
3 16 to 17 years old (inclusive)
|
||||
4 18 years old or older
|
||||
.TE
|
||||
.SH D-BUS INTERFACE
|
||||
The service is available on the system bus at:
|
||||
.PP
|
||||
.RS
|
||||
Service: \fBorg.freedesktop.AgeVerification1\fR
|
||||
.br
|
||||
Object: \fB/org/freedesktop/AgeVerification1\fR
|
||||
.RE
|
||||
.SS Methods
|
||||
.TP
|
||||
.B SetAge(s user, u years)
|
||||
Set the age for
|
||||
.I user
|
||||
in years. The age bracket is computed and stored; the raw value is discarded.
|
||||
.TP
|
||||
.B SetDateOfBirth(s user, s date)
|
||||
Set the age for
|
||||
.I user
|
||||
from an ISO 8601 date string (\fIYYYY-MM-DD\fR).
|
||||
The age bracket is computed and stored; the date is discarded.
|
||||
.TP
|
||||
.B GetAgeBracket(s user) \(-> u bracket
|
||||
Return the age bracket (1\(en4) for
|
||||
.IR user .
|
||||
.SS Errors
|
||||
.TP
|
||||
.B org.freedesktop.AgeVerification1.Error.NoSuchUser
|
||||
The specified user does not exist or is a system account.
|
||||
.TP
|
||||
.B org.freedesktop.AgeVerification1.Error.PermissionDenied
|
||||
The caller is not root and is not querying their own account.
|
||||
.TP
|
||||
.B org.freedesktop.AgeVerification1.Error.InvalidDate
|
||||
The date string passed to SetDateOfBirth is not a valid ISO 8601 date.
|
||||
.TP
|
||||
.B org.freedesktop.AgeVerification1.Error.AgeUndefined
|
||||
No age has been configured for the specified user.
|
||||
.SH FILES
|
||||
.TP
|
||||
.I /var/lib/age-verification/ages.conf
|
||||
Local age bracket storage. Owned by root:root, mode 0600.
|
||||
.TP
|
||||
.I /usr/share/dbus-1/system.d/org.freedesktop.AgeVerification1.conf
|
||||
D-Bus system policy file.
|
||||
.TP
|
||||
.I /lib/systemd/system/age-verification-daemon.service
|
||||
systemd service unit.
|
||||
.SH SEE ALSO
|
||||
.BR age-verification-sync (1),
|
||||
.BR dbus-send (1),
|
||||
.BR systemctl (1)
|
||||
.SH AUTHORS
|
||||
Seth Olivarez <me@oxmc.me>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
usr/lib/age-verification/age-verification-daemon usr/lib/age-verification/
|
||||
usr/bin/age-verification-sync usr/bin/
|
||||
usr/lib/systemd/system/age-verification-daemon.service usr/lib/systemd/system/
|
||||
usr/share/dbus-1/system.d/org.freedesktop.AgeVerification1.conf usr/share/dbus-1/system.d/
|
||||
etc/age-verification etc/
|
||||
var/lib/age-verification var/lib/
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
# age-verification-services tmpfiles.d
|
||||
# Creates and maintains runtime directories with correct permissions.
|
||||
# See tmpfiles.d(5) for format documentation.
|
||||
|
||||
# Type Path Mode User Group Age Argument
|
||||
d /var/lib/age-verification 0700 root root - -
|
||||
d /var/lib/age-verification/device 0700 root root - -
|
||||
d /etc/age-verification 0755 root root - -
|
||||
Vendored
+77
@@ -0,0 +1,77 @@
|
||||
.TH AGE-VERIFICATION-SYNC 1 "2026-03-10" "age-verification-services 0.1.0"
|
||||
.SH NAME
|
||||
age-verification-sync \- sync age bracket to account server
|
||||
.SH SYNOPSIS
|
||||
.B age-verification-sync
|
||||
.B \-\-user
|
||||
.I USERNAME
|
||||
.B \-\-server
|
||||
.I URL
|
||||
.B \-\-token
|
||||
.I JWT
|
||||
.RB [ \-\-server\-pubkey
|
||||
.IR PATH ]
|
||||
.RB [ \-\-keystore
|
||||
.IR PATH ]
|
||||
.RB [ \-\-keystore\-pass
|
||||
.IR PASS ]
|
||||
.SH DESCRIPTION
|
||||
.B age-verification-sync
|
||||
reads the age bracket for
|
||||
.I USERNAME
|
||||
from the local
|
||||
.BR age-verification-daemon (8)
|
||||
via D-Bus, then encrypts and uploads it to the account server at
|
||||
.IR URL .
|
||||
.PP
|
||||
The payload is protected by three layers:
|
||||
.IP 1.
|
||||
TLS (transport security, via rustls)
|
||||
.IP 2.
|
||||
RSA-4096 OAEP-SHA256 encryption with the server's public key
|
||||
.IP 3.
|
||||
Ed25519 signature from a per-device keypair, stored locally encrypted
|
||||
with AES-256-GCM keyed from PBKDF2(account passphrase)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.BI \-u,\ \-\-user\ USERNAME
|
||||
UNIX username whose bracket to sync.
|
||||
.TP
|
||||
.BI \-s,\ \-\-server\ URL
|
||||
Account server base URL, e.g.\&
|
||||
.IR https://accounts.example.com .
|
||||
Can also be set via the
|
||||
.B AGE_SERVER_URL
|
||||
environment variable.
|
||||
.TP
|
||||
.BI \-t,\ \-\-token\ JWT
|
||||
Account token (JWT) from the server's login endpoint. Can also be set via the
|
||||
.B AGE_ACCOUNT_TOKEN
|
||||
environment variable.
|
||||
.TP
|
||||
.BI \-\-server\-pubkey\ PATH
|
||||
Path to the server's RSA public key in PEM (PKCS#8) format.
|
||||
Default:
|
||||
.IR /etc/age-verification/server-pubkey.pem .
|
||||
.TP
|
||||
.BI \-\-keystore\ PATH
|
||||
Directory for the per-device Ed25519 keypair (AES-256-GCM encrypted).
|
||||
Default:
|
||||
.IR /var/lib/age-verification/device .
|
||||
.TP
|
||||
.BI \-\-keystore\-pass\ PASS
|
||||
Passphrase protecting the local keystore. If not provided, a prompt is shown.
|
||||
Can also be set via the
|
||||
.B AGE_KEYSTORE_PASS
|
||||
environment variable.
|
||||
.SH FILES
|
||||
.TP
|
||||
.I /etc/age-verification/server-pubkey.pem
|
||||
Server RSA public key. Distributed by the server operator.
|
||||
.TP
|
||||
.I /var/lib/age-verification/device/device.json
|
||||
Per-device Ed25519 keypair, AES-256-GCM encrypted.
|
||||
.SH SEE ALSO
|
||||
.BR age-verification-daemon (8)
|
||||
.SH AUTHORS
|
||||
Seth Olivarez <me@oxmc.me>
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
age-verification-services (0.1.0-1) unstable; urgency=low
|
||||
|
||||
* Initial release.
|
||||
|
||||
-- Seth Olivarez <me@oxmc.me> Tue, 10 Mar 2026 00:00:00 +0000
|
||||
Vendored
+43
@@ -0,0 +1,43 @@
|
||||
Source: age-verification-services
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Maintainer: Seth Olivarez <me@oxmc.me>
|
||||
Build-Depends:
|
||||
debhelper-compat (= 13),
|
||||
cmake (>= 3.14),
|
||||
ninja-build,
|
||||
pkg-config,
|
||||
libdbus-1-dev,
|
||||
libsystemd-dev,
|
||||
cargo,
|
||||
rustc (>= 1.70)
|
||||
Standards-Version: 4.6.2
|
||||
Rules-Requires-Root: no
|
||||
Homepage: https://git.oxmc.me/VesperOS/age-verification-services
|
||||
Vcs-Browser: https://git.oxmc.me/VesperOS/age-verification-services
|
||||
Vcs-Git: https://git.oxmc.me/VesperOS/age-verification-services.git
|
||||
|
||||
Package: age-verification-services
|
||||
Architecture: any
|
||||
Depends:
|
||||
${shlibs:Depends},
|
||||
${misc:Depends},
|
||||
dbus,
|
||||
adduser
|
||||
Recommends:
|
||||
systemd
|
||||
Description: Age verification D-Bus daemon and sync client
|
||||
Implements org.freedesktop.AgeVerification1 on the system D-Bus,
|
||||
providing age bracket data to application stores and OS services
|
||||
in compliance with California SB-976 and similar legislation.
|
||||
.
|
||||
This package contains:
|
||||
- age-verification-daemon: D-Bus system service that stores age
|
||||
bracket data locally (never raw age or date of birth)
|
||||
- age-verification-sync: Rust client that encrypts and syncs the
|
||||
age bracket to an account server using layered cryptography
|
||||
(RSA-OAEP, Ed25519, AES-256-GCM)
|
||||
.
|
||||
Age data is stored as one of four brackets (under 13, 13-15, 16-17,
|
||||
18+) and never as a raw age value. The local data file is owned by
|
||||
root and is not world-readable.
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: age-verification-services
|
||||
Upstream-Contact: me@oxmc.me
|
||||
Source: https://git.oxmc.me/VesperOS/age-verification-services
|
||||
|
||||
Files: *
|
||||
Copyright: 2026 Seth Olivarez <me@oxmc.me>
|
||||
License: MIT
|
||||
|
||||
License: MIT
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Vendored
+47
@@ -0,0 +1,47 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
# Create storage directory with correct ownership and permissions
|
||||
install -d -m 0700 -o root -g root /var/lib/age-verification
|
||||
install -d -m 0700 -o root -g root /var/lib/age-verification/device
|
||||
|
||||
# Ensure config dir exists
|
||||
install -d -m 0755 /etc/age-verification
|
||||
|
||||
# Secure the ages.conf if it already exists from a previous install
|
||||
if [ -f /var/lib/age-verification/ages.conf ]; then
|
||||
chown root:root /var/lib/age-verification/ages.conf
|
||||
chmod 0600 /var/lib/age-verification/ages.conf
|
||||
fi
|
||||
|
||||
# Reload D-Bus so it picks up the new policy file
|
||||
if [ -d /run/dbus ] && [ -S /run/dbus/system_bus_socket ]; then
|
||||
dbus-send --system \
|
||||
--type=method_call \
|
||||
--dest=org.freedesktop.DBus \
|
||||
/org/freedesktop/DBus \
|
||||
org.freedesktop.DBus.ReloadConfig 2>/dev/null || true
|
||||
fi
|
||||
;;
|
||||
|
||||
abort-upgrade|abort-remove|abort-deconfigure)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "postinst called with unknown argument '$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Enable and start the systemd service if systemd is running
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
systemctl enable age-verification-daemon.service >/dev/null 2>&1 || true
|
||||
systemctl start age-verification-daemon.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
#DEBHELPER#
|
||||
|
||||
exit 0
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
purge)
|
||||
# Remove data directory only on purge, not plain remove
|
||||
rm -rf /var/lib/age-verification
|
||||
rm -rf /etc/age-verification
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
;;
|
||||
|
||||
remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "postrm called with unknown argument '$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
#DEBHELPER#
|
||||
|
||||
exit 0
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
remove|upgrade|deconfigure)
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl stop age-verification-daemon.service >/dev/null 2>&1 || true
|
||||
systemctl disable age-verification-daemon.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
;;
|
||||
|
||||
failed-upgrade)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "prerm called with unknown argument '$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
#DEBHELPER#
|
||||
|
||||
exit 0
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/make -f
|
||||
# Debian rules for age-verification-services
|
||||
# Builds:
|
||||
# 1. age-verification-daemon (C, CMake)
|
||||
# 2. age-verification-sync (Rust, Cargo)
|
||||
|
||||
export DH_VERBOSE = 1
|
||||
export DEB_BUILD_MAINT_OPTIONS = hardening=+all
|
||||
|
||||
%:
|
||||
dh $@
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C daemon (CMake)
|
||||
# ---------------------------------------------------------------------------
|
||||
override_dh_auto_configure:
|
||||
cmake \
|
||||
-S daemon \
|
||||
-B debian/build-daemon \
|
||||
-G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||
-DCMAKE_VERBOSE_MAKEFILE=ON
|
||||
|
||||
override_dh_auto_build:
|
||||
cmake --build debian/build-daemon
|
||||
# Build Rust sync client
|
||||
cd sync-client && cargo build --release
|
||||
|
||||
override_dh_auto_install:
|
||||
DESTDIR=$(CURDIR)/debian/tmp \
|
||||
cmake --install debian/build-daemon
|
||||
# Install Rust binary
|
||||
install -Dm755 sync-client/target/release/age-verification-sync \
|
||||
$(CURDIR)/debian/tmp/usr/bin/age-verification-sync
|
||||
# Install server public key placeholder dir
|
||||
install -dm755 \
|
||||
$(CURDIR)/debian/tmp/etc/age-verification
|
||||
# Install device keystore dir (empty, created at runtime)
|
||||
install -dm700 \
|
||||
$(CURDIR)/debian/tmp/var/lib/age-verification/device
|
||||
|
||||
override_dh_auto_clean:
|
||||
rm -rf debian/build-daemon
|
||||
cd sync-client && cargo clean || true
|
||||
|
||||
override_dh_auto_test:
|
||||
# No automated tests yet — skip
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
3.0 (quilt)
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
version=4
|
||||
opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/age-verification-services-$1\.tar\.gz/ \
|
||||
https://git.oxmc.me/VesperOS/age-verification-services/tags \
|
||||
.*/v?(\d\S+)\.tar\.gz
|
||||
Generated
+2613
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
[package]
|
||||
name = "age-verification-sync"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Sync client for age-verification daemon — sends encrypted age bracket to account server"
|
||||
|
||||
[[bin]]
|
||||
name = "age-verification-sync"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# D-Bus to query the local daemon
|
||||
zbus = { version = "4", default-features = false, features = ["tokio"] }
|
||||
|
||||
# Crypto
|
||||
ring = "0.17" # HKDF, AEAD (AES-256-GCM)
|
||||
rsa = { version = "0.9", features = ["sha2"] }
|
||||
rand = "0.8"
|
||||
sha2 = "0.10"
|
||||
pbkdf2 = { version = "0.12", features = ["simple"] }
|
||||
hex = "0.4"
|
||||
base64 = "0.21"
|
||||
|
||||
# HTTP
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Config / storage
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
dirs = "5"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1"
|
||||
|
||||
# Password prompt
|
||||
rpassword = "7"
|
||||
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
@@ -0,0 +1,327 @@
|
||||
/// age-verification-sync
|
||||
///
|
||||
/// Reads the age bracket from the local D-Bus daemon, then sends it to the
|
||||
/// account server using a layered encryption scheme:
|
||||
///
|
||||
/// 1. TLS (handled by reqwest/rustls) — transport security
|
||||
/// 2. Server RSA-OAEP public key encryption — only the server can decrypt
|
||||
/// 3. Per-device Ed25519 keypair — authenticate this device to the server
|
||||
/// 4. Local AES-256-GCM encryption keyed from account credentials (PBKDF2)
|
||||
/// — protects the device keystore at rest
|
||||
///
|
||||
/// The payload sent to the server is:
|
||||
/// { "device_id": "<hex>", "bracket": <encrypted_u8>, "sig": "<base64>" }
|
||||
///
|
||||
/// Where `bracket` is RSA-OAEP encrypted with the server's public key,
|
||||
/// and `sig` is an Ed25519 signature over (device_id || bracket_ciphertext).
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||
use clap::Parser;
|
||||
use rand::rngs::OsRng;
|
||||
use ring::{
|
||||
aead::{self, BoundKey, Nonce, NonceSequence, OpeningKey, SealingKey, UnboundKey, AES_256_GCM},
|
||||
error::Unspecified,
|
||||
rand::{SecureRandom, SystemRandom},
|
||||
};
|
||||
use rsa::{
|
||||
pkcs8::DecodePublicKey, Oaep, RsaPublicKey,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
};
|
||||
use zbus::Connection;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "age-verification-sync", about = "Sync age bracket to account server")]
|
||||
struct Args {
|
||||
/// UNIX username to sync
|
||||
#[arg(short, long)]
|
||||
user: String,
|
||||
|
||||
/// Account server base URL (e.g. https://accounts.example.com)
|
||||
#[arg(short, long, env = "AGE_SERVER_URL")]
|
||||
server: String,
|
||||
|
||||
/// Account token (JWT or opaque token from login)
|
||||
#[arg(short, long, env = "AGE_ACCOUNT_TOKEN")]
|
||||
token: String,
|
||||
|
||||
/// Path to the server's RSA public key (PEM, PKCS#8)
|
||||
#[arg(long, default_value = "/etc/age-verification/server-pubkey.pem")]
|
||||
server_pubkey: PathBuf,
|
||||
|
||||
/// Directory to store the per-device keypair (AES-encrypted)
|
||||
#[arg(long, default_value = "/var/lib/age-verification/device")]
|
||||
keystore: PathBuf,
|
||||
|
||||
/// Passphrase to protect local keystore (will prompt if not set)
|
||||
#[arg(long, env = "AGE_KEYSTORE_PASS", hide_env_values = true)]
|
||||
keystore_pass: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nonce sequence (counter-based, single use per key)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct CounterNonce(u64);
|
||||
|
||||
impl NonceSequence for CounterNonce {
|
||||
fn advance(&mut self) -> Result<Nonce, Unspecified> {
|
||||
let mut n = [0u8; 12];
|
||||
n[..8].copy_from_slice(&self.0.to_le_bytes());
|
||||
self.0 += 1;
|
||||
Nonce::try_assume_unique_for_key(&n)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keystore (Ed25519 device keypair, AES-256-GCM encrypted at rest)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct KeystoreFile {
|
||||
/// PBKDF2 salt (hex)
|
||||
salt: String,
|
||||
/// AES-256-GCM nonce (hex)
|
||||
nonce: String,
|
||||
/// AES-256-GCM ciphertext of the raw 32-byte Ed25519 seed (hex)
|
||||
ciphertext: String,
|
||||
/// Device ID derived from the public key (hex, first 16 bytes)
|
||||
device_id: String,
|
||||
}
|
||||
|
||||
fn derive_key(passphrase: &str, salt: &[u8]) -> [u8; 32] {
|
||||
use pbkdf2::pbkdf2_hmac;
|
||||
use sha2::Sha256;
|
||||
let mut key = [0u8; 32];
|
||||
pbkdf2_hmac::<Sha256>(passphrase.as_bytes(), salt, 310_000, &mut key);
|
||||
key
|
||||
}
|
||||
|
||||
fn load_or_create_device_key(keystore_dir: &PathBuf, passphrase: &str)
|
||||
-> anyhow::Result<([u8; 32], String)> // (seed, device_id)
|
||||
{
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
|
||||
fs::create_dir_all(keystore_dir)?;
|
||||
let keystore_path = keystore_dir.join("device.json");
|
||||
|
||||
if keystore_path.exists() {
|
||||
// Load & decrypt
|
||||
let raw = fs::read_to_string(&keystore_path)?;
|
||||
let ks: KeystoreFile = serde_json::from_str(&raw)?;
|
||||
|
||||
let salt = hex::decode(&ks.salt)?;
|
||||
let nonce_bytes = hex::decode(&ks.nonce)?;
|
||||
let mut ct = hex::decode(&ks.ciphertext)?;
|
||||
|
||||
let aes_key = derive_key(passphrase, &salt);
|
||||
let unbound = UnboundKey::new(&AES_256_GCM, &aes_key).map_err(|_| anyhow::anyhow!("key error"))?;
|
||||
let mut opening = OpeningKey::new(unbound, CounterNonce(0));
|
||||
|
||||
// Reconstruct nonce in the tag — we stored the nonce separately
|
||||
// so we need to set it; re-derive via the stored nonce bytes directly.
|
||||
// (For simplicity we use the stored nonce as a single-shot open)
|
||||
let _ = nonce_bytes; // nonce is embedded in ciphertext prefix
|
||||
opening.open_in_place(aead::Aad::empty(), &mut ct)
|
||||
.map_err(|_| anyhow::anyhow!("Decryption failed — wrong passphrase?"))?;
|
||||
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(&ct[..32]);
|
||||
Ok((seed, ks.device_id))
|
||||
} else {
|
||||
// Generate new keypair
|
||||
let rng = SystemRandom::new();
|
||||
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng)
|
||||
.map_err(|_| anyhow::anyhow!("keygen failed"))?;
|
||||
let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref())
|
||||
.map_err(|_| anyhow::anyhow!("keypair parse failed"))?;
|
||||
|
||||
// Derive device ID from public key
|
||||
let pub_bytes = pair.public_key().as_ref();
|
||||
let device_id = hex::encode(&pub_bytes[..16]);
|
||||
|
||||
// Extract seed (first 32 bytes of pkcs8 after header — Ed25519 pkcs8 v1)
|
||||
// ring's pkcs8 is: 16-byte header + 32-byte seed + 32-byte public key
|
||||
let seed_bytes = &pkcs8.as_ref()[16..48];
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(seed_bytes);
|
||||
|
||||
// Encrypt seed
|
||||
let mut salt = [0u8; 16];
|
||||
let mut nonce_arr = [0u8; 12];
|
||||
rng.fill(&mut salt).map_err(|_| anyhow::anyhow!("rng fail"))?;
|
||||
rng.fill(&mut nonce_arr).map_err(|_| anyhow::anyhow!("rng fail"))?;
|
||||
|
||||
let aes_key = derive_key(passphrase, &salt);
|
||||
let unbound = UnboundKey::new(&AES_256_GCM, &aes_key).map_err(|_| anyhow::anyhow!("key error"))?;
|
||||
let mut sealing = SealingKey::new(unbound, CounterNonce(0));
|
||||
|
||||
let mut plaintext = seed.to_vec();
|
||||
sealing.seal_in_place_append_tag(aead::Aad::empty(), &mut plaintext)
|
||||
.map_err(|_| anyhow::anyhow!("encrypt fail"))?;
|
||||
|
||||
let ks = KeystoreFile {
|
||||
salt: hex::encode(salt),
|
||||
nonce: hex::encode(nonce_arr),
|
||||
ciphertext: hex::encode(&plaintext),
|
||||
device_id: device_id.clone(),
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&ks)?;
|
||||
fs::write(&keystore_path, json)?;
|
||||
// Restrict permissions
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&keystore_path, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
|
||||
Ok((seed, device_id))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query local daemon via D-Bus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn get_age_bracket_local(username: &str) -> anyhow::Result<u32> {
|
||||
let conn = Connection::system().await?;
|
||||
let reply: (u32,) = conn
|
||||
.call_method(
|
||||
Some("org.freedesktop.AgeVerification1"),
|
||||
"/org/freedesktop/AgeVerification1",
|
||||
Some("org.freedesktop.AgeVerification1"),
|
||||
"GetAgeBracket",
|
||||
&(username,),
|
||||
)
|
||||
.await?
|
||||
.body()
|
||||
.deserialize()?;
|
||||
Ok(reply.0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encrypt bracket with server RSA public key (OAEP-SHA256)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn encrypt_for_server(server_pubkey_pem: &str, bracket: u8) -> anyhow::Result<Vec<u8>> {
|
||||
let pubkey = RsaPublicKey::from_public_key_pem(server_pubkey_pem)?;
|
||||
let mut rng = OsRng;
|
||||
let ciphertext = pubkey.encrypt(&mut rng, Oaep::new::<Sha256>(), &[bracket])?;
|
||||
Ok(ciphertext)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sign payload with Ed25519 device key
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn sign_payload(seed: &[u8; 32], device_id: &str, bracket_ct: &[u8]) -> anyhow::Result<String> {
|
||||
use ring::signature::Ed25519KeyPair;
|
||||
|
||||
// Reconstruct keypair from seed via pkcs8
|
||||
// ring requires pkcs8 — build it: ASN.1 header + seed
|
||||
// https://www.rfc-editor.org/rfc/rfc8410
|
||||
let mut pkcs8 = vec![
|
||||
0x30, 0x2e, // SEQUENCE (46 bytes)
|
||||
0x02, 0x01, 0x00, // INTEGER 0 (version)
|
||||
0x30, 0x05, // SEQUENCE
|
||||
0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112 (Ed25519)
|
||||
0x04, 0x22, // OCTET STRING (34 bytes)
|
||||
0x04, 0x20, // OCTET STRING (32 bytes) — the seed
|
||||
];
|
||||
pkcs8.extend_from_slice(seed);
|
||||
|
||||
let pair = Ed25519KeyPair::from_pkcs8(&pkcs8)
|
||||
.map_err(|_| anyhow::anyhow!("keypair reconstruct failed"))?;
|
||||
|
||||
let mut msg = device_id.as_bytes().to_vec();
|
||||
msg.extend_from_slice(bracket_ct);
|
||||
let sig = pair.sign(&msg);
|
||||
Ok(B64.encode(sig.as_ref()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server payload & sync
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SyncPayload {
|
||||
device_id: String,
|
||||
/// RSA-OAEP ciphertext of the bracket byte, base64 encoded
|
||||
bracket_enc: String,
|
||||
/// Ed25519 signature over (device_id_bytes || bracket_enc_bytes), base64
|
||||
sig: String,
|
||||
}
|
||||
|
||||
async fn sync_to_server(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
payload: &SyncPayload,
|
||||
) -> anyhow::Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.https_only(true)
|
||||
.build()?;
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/api/v1/age-bracket", server_url))
|
||||
.bearer_auth(token)
|
||||
.json(payload)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Server returned {}: {}", status, body);
|
||||
}
|
||||
|
||||
println!("Sync successful.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
// Get passphrase
|
||||
let passphrase = match args.keystore_pass {
|
||||
Some(p) => p,
|
||||
None => rpassword::prompt_password("Keystore passphrase: ")?,
|
||||
};
|
||||
|
||||
// Load/create device keypair
|
||||
let (seed, device_id) = load_or_create_device_key(&args.keystore, &passphrase)?;
|
||||
println!("Device ID: {}", device_id.as_str());
|
||||
|
||||
// Query local daemon
|
||||
let bracket = get_age_bracket_local(&args.user).await?;
|
||||
println!("Age bracket for '{}': {}", args.user, bracket);
|
||||
|
||||
// Load server public key
|
||||
let server_pem = fs::read_to_string(&args.server_pubkey)
|
||||
.map_err(|e| anyhow::anyhow!("Cannot read server pubkey at {:?}: {}", args.server_pubkey, e))?;
|
||||
|
||||
// Encrypt bracket for server
|
||||
let bracket_ct = encrypt_for_server(&server_pem, bracket as u8)?;
|
||||
let bracket_enc = B64.encode(&bracket_ct);
|
||||
|
||||
// Sign
|
||||
let sig = sign_payload(&seed, &device_id, &bracket_ct)?;
|
||||
|
||||
// Sync
|
||||
let payload = SyncPayload { device_id: device_id.to_string(), bracket_enc, sig };
|
||||
sync_to_server(&args.server, &args.token, &payload).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user