Initial idea

This commit is contained in:
2026-03-10 19:14:51 -07:00
commit 0661ded273
22 changed files with 4050 additions and 0 deletions
+12
View File
@@ -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
+224
View File
@@ -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 (14), not raw age |
| Minimum data on server | Server stores bracket (14), 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`
+24
View File
@@ -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)
+358
View File
@@ -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;
}
+31
View File
@@ -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>
+78
View File
@@ -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
View File
@@ -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
View File
@@ -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 - -
+77
View File
@@ -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>
+5
View File
@@ -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
+43
View File
@@ -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.
+27
View File
@@ -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.
+47
View File
@@ -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
+25
View File
@@ -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
+23
View File
@@ -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
Vendored Executable
+48
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
3.0 (quilt)
+4
View File
@@ -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
+2613
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -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
+327
View File
@@ -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(())
}