feat(M1): TCP/TLS control plane -- auth, channels, ephemeral text
Implements the full M1 milestone. Two clients authenticate over TLS 1.3
(guest + Argon2id password) and exchange channel + private text messages
through a real server. All five ctest --preset m1-dev tests pass in ~1 s.
Key components added:
- vcpkg baseline + m1-dev preset (protobuf/mbedTLS/libsodium/asio/sqlite3)
- FrameCodec feed+emit, encode/decode_envelope, protobuf codegen
- TcpServerConn with blocking TLS handshake thread + tls_read_loop
- TlsContext (mbedTLS 1.3, ECDSA-P256 self-signed cert, TOFU on client)
- WorkerPool (3 threads, used for Argon2id)
- Database: SQLite + libsodium Argon2id, account lifecycle, bootstrap admin
- ServerIdentityManager: Ed25519 key + cert generate/persist/fingerprint
- ConnSession state machine: WaitingHello -> WaitingAuth -> Authenticated
- SessionRegistry: channel tree, user map, text routing, broadcast
- vc_client full M1 C ABI: connect/TLS/handshake/auth/text/disconnect
- voicecat-admin CLI: account add/reset/del/list
- test_m1_integration: M1 exit criterion, verified green
Bug fixed: double-framing in ConnSession::send_envelope -- encode_envelope
was adding the [4-byte len] prefix, then TcpServerConn::send_frame added
a second one, causing the client to parse [len][proto] as protobuf (silent
failure). Fixed by serializing raw protobuf bytes in send_envelope and
letting send_frame apply the single length prefix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:48:44 +02:00
|
|
|
#include "db.h"
|
|
|
|
|
|
|
|
|
|
#ifdef VOICECAT_HAS_NET
|
|
|
|
|
|
|
|
|
|
#include <chrono>
|
|
|
|
|
#include <cstring>
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
#include <random>
|
feat(M1): TCP/TLS control plane -- auth, channels, ephemeral text
Implements the full M1 milestone. Two clients authenticate over TLS 1.3
(guest + Argon2id password) and exchange channel + private text messages
through a real server. All five ctest --preset m1-dev tests pass in ~1 s.
Key components added:
- vcpkg baseline + m1-dev preset (protobuf/mbedTLS/libsodium/asio/sqlite3)
- FrameCodec feed+emit, encode/decode_envelope, protobuf codegen
- TcpServerConn with blocking TLS handshake thread + tls_read_loop
- TlsContext (mbedTLS 1.3, ECDSA-P256 self-signed cert, TOFU on client)
- WorkerPool (3 threads, used for Argon2id)
- Database: SQLite + libsodium Argon2id, account lifecycle, bootstrap admin
- ServerIdentityManager: Ed25519 key + cert generate/persist/fingerprint
- ConnSession state machine: WaitingHello -> WaitingAuth -> Authenticated
- SessionRegistry: channel tree, user map, text routing, broadcast
- vc_client full M1 C ABI: connect/TLS/handshake/auth/text/disconnect
- voicecat-admin CLI: account add/reset/del/list
- test_m1_integration: M1 exit criterion, verified green
Bug fixed: double-framing in ConnSession::send_envelope -- encode_envelope
was adding the [4-byte len] prefix, then TcpServerConn::send_frame added
a second one, causing the client to parse [len][proto] as protobuf (silent
failure). Fixed by serializing raw protobuf bytes in send_envelope and
letting send_frame apply the single length prefix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:48:44 +02:00
|
|
|
#include <stdexcept>
|
|
|
|
|
|
|
|
|
|
#include <sodium.h>
|
|
|
|
|
#include <sqlite3.h>
|
|
|
|
|
|
|
|
|
|
namespace voicecat::server {
|
|
|
|
|
|
|
|
|
|
// ── Schema ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
static constexpr int kCurrentSchemaVersion = 2;
|
|
|
|
|
|
|
|
|
|
static constexpr const char* kCreateSchemaV1 = R"sql(
|
feat(M1): TCP/TLS control plane -- auth, channels, ephemeral text
Implements the full M1 milestone. Two clients authenticate over TLS 1.3
(guest + Argon2id password) and exchange channel + private text messages
through a real server. All five ctest --preset m1-dev tests pass in ~1 s.
Key components added:
- vcpkg baseline + m1-dev preset (protobuf/mbedTLS/libsodium/asio/sqlite3)
- FrameCodec feed+emit, encode/decode_envelope, protobuf codegen
- TcpServerConn with blocking TLS handshake thread + tls_read_loop
- TlsContext (mbedTLS 1.3, ECDSA-P256 self-signed cert, TOFU on client)
- WorkerPool (3 threads, used for Argon2id)
- Database: SQLite + libsodium Argon2id, account lifecycle, bootstrap admin
- ServerIdentityManager: Ed25519 key + cert generate/persist/fingerprint
- ConnSession state machine: WaitingHello -> WaitingAuth -> Authenticated
- SessionRegistry: channel tree, user map, text routing, broadcast
- vc_client full M1 C ABI: connect/TLS/handshake/auth/text/disconnect
- voicecat-admin CLI: account add/reset/del/list
- test_m1_integration: M1 exit criterion, verified green
Bug fixed: double-framing in ConnSession::send_envelope -- encode_envelope
was adding the [4-byte len] prefix, then TcpServerConn::send_frame added
a second one, causing the client to parse [len][proto] as protobuf (silent
failure). Fixed by serializing raw protobuf bytes in send_envelope and
letting send_frame apply the single length prefix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:48:44 +02:00
|
|
|
CREATE TABLE IF NOT EXISTS accounts (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
username TEXT UNIQUE NOT NULL,
|
|
|
|
|
pw_hash TEXT NOT NULL,
|
|
|
|
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
created_at INTEGER NOT NULL,
|
|
|
|
|
last_login INTEGER NOT NULL DEFAULT 0
|
|
|
|
|
);
|
|
|
|
|
CREATE TABLE IF NOT EXISTS server_meta (
|
|
|
|
|
key TEXT PRIMARY KEY,
|
|
|
|
|
value TEXT NOT NULL
|
|
|
|
|
);
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
)sql";
|
|
|
|
|
|
|
|
|
|
static constexpr const char* kCreateSchemaV2 = R"sql(
|
|
|
|
|
CREATE TABLE IF NOT EXISTS channels (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
parent_id INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
name TEXT UNIQUE NOT NULL,
|
|
|
|
|
topic TEXT NOT NULL DEFAULT '',
|
|
|
|
|
password_hash TEXT NOT NULL DEFAULT '',
|
|
|
|
|
max_users INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
type INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
audio_codec INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
audio_mode INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
audio_sample_rate INTEGER NOT NULL DEFAULT 48000,
|
|
|
|
|
audio_bitrate_bps INTEGER NOT NULL DEFAULT 24000,
|
|
|
|
|
audio_frame_ms INTEGER NOT NULL DEFAULT 20,
|
|
|
|
|
audio_application INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
audio_fec INTEGER NOT NULL DEFAULT 1,
|
|
|
|
|
audio_expected_packet_loss INTEGER NOT NULL DEFAULT 10,
|
|
|
|
|
audio_dtx INTEGER NOT NULL DEFAULT 1,
|
|
|
|
|
audio_complexity INTEGER NOT NULL DEFAULT 5,
|
|
|
|
|
sort_order INTEGER NOT NULL DEFAULT 0
|
|
|
|
|
);
|
|
|
|
|
CREATE TABLE IF NOT EXISTS bans (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
subject_type TEXT NOT NULL,
|
|
|
|
|
subject TEXT NOT NULL,
|
|
|
|
|
reason TEXT NOT NULL DEFAULT '',
|
|
|
|
|
expires_at INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
created_at INTEGER NOT NULL
|
|
|
|
|
);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_bans_subject ON bans(subject_type, subject);
|
feat(M1): TCP/TLS control plane -- auth, channels, ephemeral text
Implements the full M1 milestone. Two clients authenticate over TLS 1.3
(guest + Argon2id password) and exchange channel + private text messages
through a real server. All five ctest --preset m1-dev tests pass in ~1 s.
Key components added:
- vcpkg baseline + m1-dev preset (protobuf/mbedTLS/libsodium/asio/sqlite3)
- FrameCodec feed+emit, encode/decode_envelope, protobuf codegen
- TcpServerConn with blocking TLS handshake thread + tls_read_loop
- TlsContext (mbedTLS 1.3, ECDSA-P256 self-signed cert, TOFU on client)
- WorkerPool (3 threads, used for Argon2id)
- Database: SQLite + libsodium Argon2id, account lifecycle, bootstrap admin
- ServerIdentityManager: Ed25519 key + cert generate/persist/fingerprint
- ConnSession state machine: WaitingHello -> WaitingAuth -> Authenticated
- SessionRegistry: channel tree, user map, text routing, broadcast
- vc_client full M1 C ABI: connect/TLS/handshake/auth/text/disconnect
- voicecat-admin CLI: account add/reset/del/list
- test_m1_integration: M1 exit criterion, verified green
Bug fixed: double-framing in ConnSession::send_envelope -- encode_envelope
was adding the [4-byte len] prefix, then TcpServerConn::send_frame added
a second one, causing the client to parse [len][proto] as protobuf (silent
failure). Fixed by serializing raw protobuf bytes in send_envelope and
letting send_frame apply the single length prefix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:48:44 +02:00
|
|
|
)sql";
|
|
|
|
|
|
|
|
|
|
// ── Database ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
Database::Database(std::string path) : path_(std::move(path)) {}
|
|
|
|
|
|
|
|
|
|
Database::~Database() {
|
|
|
|
|
if (db_) { sqlite3_close(db_); db_ = nullptr; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Database::open(std::string& error) {
|
|
|
|
|
int rc = sqlite3_open(path_.c_str(), &db_);
|
|
|
|
|
if (rc != SQLITE_OK) {
|
|
|
|
|
error = sqlite3_errmsg(db_);
|
|
|
|
|
sqlite3_close(db_);
|
|
|
|
|
db_ = nullptr;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
sqlite3_busy_timeout(db_, 5000);
|
|
|
|
|
// WAL mode for concurrency
|
|
|
|
|
exec("PRAGMA journal_mode=WAL", error);
|
|
|
|
|
exec("PRAGMA synchronous=NORMAL", error);
|
|
|
|
|
error.clear();
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
|
|
|
|
|
if (!exec(kCreateSchemaV1, error)) return false;
|
|
|
|
|
if (!migrate(error)) return false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Database::migrate(std::string& error) {
|
|
|
|
|
// Ensure server_meta row exists.
|
|
|
|
|
if (!exec("INSERT OR IGNORE INTO server_meta (key, value) VALUES ('schema_version', '1')",
|
|
|
|
|
error))
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
|
|
sqlite3_stmt* stmt = nullptr;
|
|
|
|
|
sqlite3_prepare_v2(db_, "SELECT value FROM server_meta WHERE key='schema_version'",
|
|
|
|
|
-1, &stmt, nullptr);
|
|
|
|
|
int version = 1;
|
|
|
|
|
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
|
|
|
|
version = std::max(1, std::atoi(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0))));
|
|
|
|
|
}
|
|
|
|
|
sqlite3_finalize(stmt);
|
|
|
|
|
|
|
|
|
|
if (version < 2) {
|
|
|
|
|
if (!exec(kCreateSchemaV2, error)) return false;
|
|
|
|
|
sqlite3_stmt* upd = nullptr;
|
|
|
|
|
sqlite3_prepare_v2(db_,
|
|
|
|
|
"INSERT OR REPLACE INTO server_meta (key, value) VALUES ('schema_version', ?)",
|
|
|
|
|
-1, &upd, nullptr);
|
|
|
|
|
sqlite3_bind_text(upd, 1, "2", -1, SQLITE_TRANSIENT);
|
|
|
|
|
sqlite3_step(upd);
|
|
|
|
|
sqlite3_finalize(upd);
|
|
|
|
|
}
|
feat(M1): TCP/TLS control plane -- auth, channels, ephemeral text
Implements the full M1 milestone. Two clients authenticate over TLS 1.3
(guest + Argon2id password) and exchange channel + private text messages
through a real server. All five ctest --preset m1-dev tests pass in ~1 s.
Key components added:
- vcpkg baseline + m1-dev preset (protobuf/mbedTLS/libsodium/asio/sqlite3)
- FrameCodec feed+emit, encode/decode_envelope, protobuf codegen
- TcpServerConn with blocking TLS handshake thread + tls_read_loop
- TlsContext (mbedTLS 1.3, ECDSA-P256 self-signed cert, TOFU on client)
- WorkerPool (3 threads, used for Argon2id)
- Database: SQLite + libsodium Argon2id, account lifecycle, bootstrap admin
- ServerIdentityManager: Ed25519 key + cert generate/persist/fingerprint
- ConnSession state machine: WaitingHello -> WaitingAuth -> Authenticated
- SessionRegistry: channel tree, user map, text routing, broadcast
- vc_client full M1 C ABI: connect/TLS/handshake/auth/text/disconnect
- voicecat-admin CLI: account add/reset/del/list
- test_m1_integration: M1 exit criterion, verified green
Bug fixed: double-framing in ConnSession::send_envelope -- encode_envelope
was adding the [4-byte len] prefix, then TcpServerConn::send_frame added
a second one, causing the client to parse [len][proto] as protobuf (silent
failure). Fixed by serializing raw protobuf bytes in send_envelope and
letting send_frame apply the single length prefix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:48:44 +02:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Database::is_empty() {
|
|
|
|
|
sqlite3_stmt* stmt = nullptr;
|
|
|
|
|
sqlite3_prepare_v2(db_, "SELECT COUNT(*) FROM accounts", -1, &stmt, nullptr);
|
|
|
|
|
int count = 0;
|
|
|
|
|
if (sqlite3_step(stmt) == SQLITE_ROW) count = sqlite3_column_int(stmt, 0);
|
|
|
|
|
sqlite3_finalize(stmt);
|
|
|
|
|
return count == 0;
|
|
|
|
|
}
|
|
|
|
|
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
// ── Accounts ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
feat(M1): TCP/TLS control plane -- auth, channels, ephemeral text
Implements the full M1 milestone. Two clients authenticate over TLS 1.3
(guest + Argon2id password) and exchange channel + private text messages
through a real server. All five ctest --preset m1-dev tests pass in ~1 s.
Key components added:
- vcpkg baseline + m1-dev preset (protobuf/mbedTLS/libsodium/asio/sqlite3)
- FrameCodec feed+emit, encode/decode_envelope, protobuf codegen
- TcpServerConn with blocking TLS handshake thread + tls_read_loop
- TlsContext (mbedTLS 1.3, ECDSA-P256 self-signed cert, TOFU on client)
- WorkerPool (3 threads, used for Argon2id)
- Database: SQLite + libsodium Argon2id, account lifecycle, bootstrap admin
- ServerIdentityManager: Ed25519 key + cert generate/persist/fingerprint
- ConnSession state machine: WaitingHello -> WaitingAuth -> Authenticated
- SessionRegistry: channel tree, user map, text routing, broadcast
- vc_client full M1 C ABI: connect/TLS/handshake/auth/text/disconnect
- voicecat-admin CLI: account add/reset/del/list
- test_m1_integration: M1 exit criterion, verified green
Bug fixed: double-framing in ConnSession::send_envelope -- encode_envelope
was adding the [4-byte len] prefix, then TcpServerConn::send_frame added
a second one, causing the client to parse [len][proto] as protobuf (silent
failure). Fixed by serializing raw protobuf bytes in send_envelope and
letting send_frame apply the single length prefix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:48:44 +02:00
|
|
|
std::optional<Account> Database::create_account(const std::string& username,
|
|
|
|
|
const std::string& password,
|
|
|
|
|
bool is_admin, std::string& error) {
|
|
|
|
|
if (username.empty() || password.empty()) {
|
|
|
|
|
error = "username and password must not be empty";
|
|
|
|
|
return std::nullopt;
|
|
|
|
|
}
|
|
|
|
|
// Hash with Argon2id via libsodium
|
|
|
|
|
char hash[crypto_pwhash_STRBYTES];
|
|
|
|
|
if (crypto_pwhash_str(hash, password.c_str(), password.size(),
|
|
|
|
|
crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
|
|
|
|
crypto_pwhash_MEMLIMIT_INTERACTIVE) != 0) {
|
|
|
|
|
error = "Argon2id hashing failed (OOM?)";
|
|
|
|
|
return std::nullopt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int64_t now = now_unix();
|
|
|
|
|
sqlite3_stmt* stmt = nullptr;
|
|
|
|
|
int rc = sqlite3_prepare_v2(db_,
|
|
|
|
|
"INSERT INTO accounts (username, pw_hash, is_admin, created_at) VALUES (?,?,?,?)",
|
|
|
|
|
-1, &stmt, nullptr);
|
|
|
|
|
if (rc != SQLITE_OK) { error = sqlite3_errmsg(db_); return std::nullopt; }
|
|
|
|
|
|
|
|
|
|
sqlite3_bind_text(stmt, 1, username.c_str(), -1, SQLITE_TRANSIENT);
|
|
|
|
|
sqlite3_bind_text(stmt, 2, hash, -1, SQLITE_TRANSIENT);
|
|
|
|
|
sqlite3_bind_int(stmt, 3, is_admin ? 1 : 0);
|
|
|
|
|
sqlite3_bind_int64(stmt, 4, now);
|
|
|
|
|
rc = sqlite3_step(stmt);
|
|
|
|
|
sqlite3_finalize(stmt);
|
|
|
|
|
|
|
|
|
|
if (rc != SQLITE_DONE) {
|
|
|
|
|
error = sqlite3_errmsg(db_);
|
|
|
|
|
return std::nullopt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Account acc;
|
|
|
|
|
acc.id = sqlite3_last_insert_rowid(db_);
|
|
|
|
|
acc.username = username;
|
|
|
|
|
acc.is_admin = is_admin;
|
|
|
|
|
acc.created_at = now;
|
|
|
|
|
return acc;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Database::reset_password(const std::string& username, const std::string& new_password,
|
|
|
|
|
std::string& error) {
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
if (new_password.empty()) { error = "password must not be empty"; return false; }
|
feat(M1): TCP/TLS control plane -- auth, channels, ephemeral text
Implements the full M1 milestone. Two clients authenticate over TLS 1.3
(guest + Argon2id password) and exchange channel + private text messages
through a real server. All five ctest --preset m1-dev tests pass in ~1 s.
Key components added:
- vcpkg baseline + m1-dev preset (protobuf/mbedTLS/libsodium/asio/sqlite3)
- FrameCodec feed+emit, encode/decode_envelope, protobuf codegen
- TcpServerConn with blocking TLS handshake thread + tls_read_loop
- TlsContext (mbedTLS 1.3, ECDSA-P256 self-signed cert, TOFU on client)
- WorkerPool (3 threads, used for Argon2id)
- Database: SQLite + libsodium Argon2id, account lifecycle, bootstrap admin
- ServerIdentityManager: Ed25519 key + cert generate/persist/fingerprint
- ConnSession state machine: WaitingHello -> WaitingAuth -> Authenticated
- SessionRegistry: channel tree, user map, text routing, broadcast
- vc_client full M1 C ABI: connect/TLS/handshake/auth/text/disconnect
- voicecat-admin CLI: account add/reset/del/list
- test_m1_integration: M1 exit criterion, verified green
Bug fixed: double-framing in ConnSession::send_envelope -- encode_envelope
was adding the [4-byte len] prefix, then TcpServerConn::send_frame added
a second one, causing the client to parse [len][proto] as protobuf (silent
failure). Fixed by serializing raw protobuf bytes in send_envelope and
letting send_frame apply the single length prefix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:48:44 +02:00
|
|
|
char hash[crypto_pwhash_STRBYTES];
|
|
|
|
|
if (crypto_pwhash_str(hash, new_password.c_str(), new_password.size(),
|
|
|
|
|
crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
|
|
|
|
crypto_pwhash_MEMLIMIT_INTERACTIVE) != 0) {
|
|
|
|
|
error = "Argon2id hashing failed";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
sqlite3_stmt* stmt = nullptr;
|
|
|
|
|
sqlite3_prepare_v2(db_, "UPDATE accounts SET pw_hash=? WHERE username=?", -1, &stmt, nullptr);
|
|
|
|
|
sqlite3_bind_text(stmt, 1, hash, -1, SQLITE_TRANSIENT);
|
|
|
|
|
sqlite3_bind_text(stmt, 2, username.c_str(), -1, SQLITE_TRANSIENT);
|
|
|
|
|
int rc = sqlite3_step(stmt);
|
|
|
|
|
sqlite3_finalize(stmt);
|
|
|
|
|
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
|
|
|
|
|
if (sqlite3_changes(db_) == 0) { error = "user not found: " + username; return false; }
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Database::delete_account(const std::string& username, std::string& error) {
|
|
|
|
|
sqlite3_stmt* stmt = nullptr;
|
|
|
|
|
sqlite3_prepare_v2(db_, "DELETE FROM accounts WHERE username=?", -1, &stmt, nullptr);
|
|
|
|
|
sqlite3_bind_text(stmt, 1, username.c_str(), -1, SQLITE_TRANSIENT);
|
|
|
|
|
int rc = sqlite3_step(stmt);
|
|
|
|
|
sqlite3_finalize(stmt);
|
|
|
|
|
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
|
|
|
|
|
if (sqlite3_changes(db_) == 0) { error = "user not found: " + username; return false; }
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::vector<Account> Database::list_accounts() {
|
|
|
|
|
std::vector<Account> result;
|
|
|
|
|
sqlite3_stmt* stmt = nullptr;
|
|
|
|
|
sqlite3_prepare_v2(db_,
|
|
|
|
|
"SELECT id, username, is_admin, created_at, last_login FROM accounts ORDER BY username",
|
|
|
|
|
-1, &stmt, nullptr);
|
|
|
|
|
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
|
|
|
|
Account acc;
|
|
|
|
|
acc.id = sqlite3_column_int64(stmt, 0);
|
|
|
|
|
acc.username = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
|
|
|
|
acc.is_admin = sqlite3_column_int(stmt, 2) != 0;
|
|
|
|
|
acc.created_at = sqlite3_column_int64(stmt, 3);
|
|
|
|
|
acc.last_login = sqlite3_column_int64(stmt, 4);
|
|
|
|
|
result.push_back(acc);
|
|
|
|
|
}
|
|
|
|
|
sqlite3_finalize(stmt);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::optional<Account> Database::authenticate(const std::string& username,
|
|
|
|
|
const std::string& password) {
|
|
|
|
|
sqlite3_stmt* stmt = nullptr;
|
|
|
|
|
sqlite3_prepare_v2(db_,
|
|
|
|
|
"SELECT id, pw_hash, is_admin, created_at, last_login FROM accounts WHERE username=?",
|
|
|
|
|
-1, &stmt, nullptr);
|
|
|
|
|
sqlite3_bind_text(stmt, 1, username.c_str(), -1, SQLITE_TRANSIENT);
|
|
|
|
|
int rc = sqlite3_step(stmt);
|
|
|
|
|
if (rc != SQLITE_ROW) { sqlite3_finalize(stmt); return std::nullopt; }
|
|
|
|
|
|
|
|
|
|
int64_t id = sqlite3_column_int64(stmt, 0);
|
|
|
|
|
std::string hash = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
|
|
|
|
bool is_admin = sqlite3_column_int(stmt, 2) != 0;
|
|
|
|
|
int64_t created = sqlite3_column_int64(stmt, 3);
|
|
|
|
|
sqlite3_finalize(stmt);
|
|
|
|
|
|
|
|
|
|
// Verify Argon2id — deliberately slow
|
|
|
|
|
if (crypto_pwhash_str_verify(hash.c_str(), password.c_str(), password.size()) != 0)
|
|
|
|
|
return std::nullopt;
|
|
|
|
|
|
|
|
|
|
// Update last_login
|
|
|
|
|
int64_t now = now_unix();
|
|
|
|
|
sqlite3_stmt* upd = nullptr;
|
|
|
|
|
sqlite3_prepare_v2(db_, "UPDATE accounts SET last_login=? WHERE id=?", -1, &upd, nullptr);
|
|
|
|
|
sqlite3_bind_int64(upd, 1, now);
|
|
|
|
|
sqlite3_bind_int64(upd, 2, id);
|
|
|
|
|
sqlite3_step(upd);
|
|
|
|
|
sqlite3_finalize(upd);
|
|
|
|
|
|
|
|
|
|
Account acc;
|
|
|
|
|
acc.id = id;
|
|
|
|
|
acc.username = username;
|
|
|
|
|
acc.is_admin = is_admin;
|
|
|
|
|
acc.created_at = created;
|
|
|
|
|
acc.last_login = now;
|
|
|
|
|
return acc;
|
|
|
|
|
}
|
|
|
|
|
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
// ── Channels ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
void bind_audio(sqlite3_stmt* stmt, int base_idx, const voicecat::v1::AudioConfig& a) {
|
|
|
|
|
sqlite3_bind_int(stmt, base_idx + 0, static_cast<int>(a.codec()));
|
|
|
|
|
sqlite3_bind_int(stmt, base_idx + 1, static_cast<int>(a.mode()));
|
|
|
|
|
sqlite3_bind_int(stmt, base_idx + 2, static_cast<int>(a.sample_rate()));
|
|
|
|
|
sqlite3_bind_int(stmt, base_idx + 3, static_cast<int>(a.bitrate_bps()));
|
|
|
|
|
sqlite3_bind_int(stmt, base_idx + 4, static_cast<int>(a.frame_ms()));
|
|
|
|
|
sqlite3_bind_int(stmt, base_idx + 5, static_cast<int>(a.application()));
|
|
|
|
|
sqlite3_bind_int(stmt, base_idx + 6, a.fec() ? 1 : 0);
|
|
|
|
|
sqlite3_bind_int(stmt, base_idx + 7, static_cast<int>(a.expected_packet_loss()));
|
|
|
|
|
sqlite3_bind_int(stmt, base_idx + 8, a.dtx() ? 1 : 0);
|
|
|
|
|
sqlite3_bind_int(stmt, base_idx + 9, static_cast<int>(a.complexity()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
voicecat::v1::AudioConfig read_audio(sqlite3_stmt* stmt, int base_idx) {
|
|
|
|
|
voicecat::v1::AudioConfig a;
|
|
|
|
|
a.set_codec(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 0)));
|
|
|
|
|
a.set_mode(static_cast<voicecat::v1::ChannelMode>(sqlite3_column_int(stmt, base_idx + 1)));
|
|
|
|
|
a.set_sample_rate(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 2)));
|
|
|
|
|
a.set_bitrate_bps(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 3)));
|
|
|
|
|
a.set_frame_ms(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 4)));
|
|
|
|
|
a.set_application(static_cast<voicecat::v1::OpusApplication>(sqlite3_column_int(stmt, base_idx + 5)));
|
|
|
|
|
a.set_fec(sqlite3_column_int(stmt, base_idx + 6) != 0);
|
|
|
|
|
a.set_expected_packet_loss(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 7)));
|
|
|
|
|
a.set_dtx(sqlite3_column_int(stmt, base_idx + 8) != 0);
|
|
|
|
|
a.set_complexity(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 9)));
|
|
|
|
|
return a;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
std::optional<ChannelRecord> Database::create_channel(const ChannelRecord& ch,
|
|
|
|
|
const std::string& password,
|
|
|
|
|
std::string& error) {
|
|
|
|
|
if (ch.name.empty()) { error = "channel name must not be empty"; return std::nullopt; }
|
|
|
|
|
|
|
|
|
|
std::string pw_hash = password.empty() ? "" : hash_channel_password(password);
|
|
|
|
|
|
|
|
|
|
sqlite3_stmt* stmt = nullptr;
|
|
|
|
|
const char* sql = R"sql(
|
|
|
|
|
INSERT INTO channels
|
|
|
|
|
(parent_id, name, topic, password_hash, max_users, type,
|
|
|
|
|
audio_codec, audio_mode, audio_sample_rate, audio_bitrate_bps, audio_frame_ms,
|
|
|
|
|
audio_application, audio_fec, audio_expected_packet_loss, audio_dtx, audio_complexity,
|
|
|
|
|
sort_order)
|
|
|
|
|
VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?, ?)
|
|
|
|
|
)sql";
|
|
|
|
|
int rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
|
|
|
|
|
if (rc != SQLITE_OK) { error = sqlite3_errmsg(db_); return std::nullopt; }
|
|
|
|
|
|
|
|
|
|
sqlite3_bind_int(stmt, 1, static_cast<int>(ch.parent_id));
|
|
|
|
|
sqlite3_bind_text(stmt, 2, ch.name.c_str(), -1, SQLITE_TRANSIENT);
|
|
|
|
|
sqlite3_bind_text(stmt, 3, ch.topic.c_str(), -1, SQLITE_TRANSIENT);
|
|
|
|
|
sqlite3_bind_text(stmt, 4, pw_hash.c_str(), -1, SQLITE_TRANSIENT);
|
|
|
|
|
sqlite3_bind_int(stmt, 5, static_cast<int>(ch.max_users));
|
|
|
|
|
sqlite3_bind_int(stmt, 6, static_cast<int>(ch.type));
|
|
|
|
|
bind_audio(stmt, 7, ch.audio); // parameters 7-16
|
|
|
|
|
sqlite3_bind_int(stmt, 17, static_cast<int>(ch.sort_order));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
rc = sqlite3_step(stmt);
|
|
|
|
|
sqlite3_finalize(stmt);
|
|
|
|
|
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return std::nullopt; }
|
|
|
|
|
|
|
|
|
|
ChannelRecord out = ch;
|
|
|
|
|
out.id = static_cast<uint32_t>(sqlite3_last_insert_rowid(db_));
|
|
|
|
|
out.password_protected = !pw_hash.empty();
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Database::update_channel(const ChannelRecord& ch, const std::string& password,
|
|
|
|
|
std::string& error) {
|
|
|
|
|
std::string pw_hash;
|
|
|
|
|
bool update_password = false;
|
|
|
|
|
if (!password.empty()) {
|
|
|
|
|
pw_hash = hash_channel_password(password);
|
|
|
|
|
update_password = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string sql = R"sql(
|
|
|
|
|
UPDATE channels SET
|
|
|
|
|
parent_id=?, name=?, topic=?, max_users=?, type=?,
|
|
|
|
|
audio_codec=?, audio_mode=?, audio_sample_rate=?, audio_bitrate_bps=?, audio_frame_ms=?,
|
|
|
|
|
audio_application=?, audio_fec=?, audio_expected_packet_loss=?, audio_dtx=?, audio_complexity=?,
|
|
|
|
|
sort_order=?
|
|
|
|
|
)sql";
|
|
|
|
|
if (update_password) sql += ", password_hash=?";
|
|
|
|
|
sql += " WHERE id=?";
|
|
|
|
|
|
|
|
|
|
sqlite3_stmt* stmt = nullptr;
|
|
|
|
|
int rc = sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr);
|
|
|
|
|
if (rc != SQLITE_OK) { error = sqlite3_errmsg(db_); return false; }
|
|
|
|
|
|
|
|
|
|
int idx = 1;
|
|
|
|
|
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.parent_id));
|
|
|
|
|
sqlite3_bind_text(stmt, idx++, ch.name.c_str(), -1, SQLITE_TRANSIENT);
|
|
|
|
|
sqlite3_bind_text(stmt, idx++, ch.topic.c_str(), -1, SQLITE_TRANSIENT);
|
|
|
|
|
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.max_users));
|
|
|
|
|
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.type));
|
|
|
|
|
bind_audio(stmt, idx, ch.audio); idx += 10;
|
|
|
|
|
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.sort_order));
|
|
|
|
|
if (update_password) {
|
|
|
|
|
sqlite3_bind_text(stmt, idx++, pw_hash.c_str(), -1, SQLITE_TRANSIENT);
|
|
|
|
|
}
|
|
|
|
|
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.id));
|
|
|
|
|
|
|
|
|
|
rc = sqlite3_step(stmt);
|
|
|
|
|
sqlite3_finalize(stmt);
|
|
|
|
|
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
|
|
|
|
|
if (sqlite3_changes(db_) == 0) { error = "channel not found"; return false; }
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Database::delete_channel(uint32_t id, std::string& error) {
|
|
|
|
|
sqlite3_stmt* stmt = nullptr;
|
|
|
|
|
sqlite3_prepare_v2(db_, "DELETE FROM channels WHERE id=?", -1, &stmt, nullptr);
|
|
|
|
|
sqlite3_bind_int(stmt, 1, static_cast<int>(id));
|
|
|
|
|
int rc = sqlite3_step(stmt);
|
|
|
|
|
sqlite3_finalize(stmt);
|
|
|
|
|
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
|
|
|
|
|
if (sqlite3_changes(db_) == 0) { error = "channel not found"; return false; }
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::vector<ChannelRecord> Database::list_channels() {
|
|
|
|
|
std::vector<ChannelRecord> result;
|
|
|
|
|
sqlite3_stmt* stmt = nullptr;
|
|
|
|
|
sqlite3_prepare_v2(db_, R"sql(
|
|
|
|
|
SELECT id, parent_id, name, topic, password_hash, max_users, type,
|
|
|
|
|
audio_codec, audio_mode, audio_sample_rate, audio_bitrate_bps, audio_frame_ms,
|
|
|
|
|
audio_application, audio_fec, audio_expected_packet_loss, audio_dtx, audio_complexity,
|
|
|
|
|
sort_order
|
|
|
|
|
FROM channels
|
|
|
|
|
ORDER BY sort_order, id
|
|
|
|
|
)sql", -1, &stmt, nullptr);
|
|
|
|
|
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
|
|
|
|
ChannelRecord ch;
|
|
|
|
|
ch.id = static_cast<uint32_t>(sqlite3_column_int(stmt, 0));
|
|
|
|
|
ch.parent_id = static_cast<uint32_t>(sqlite3_column_int(stmt, 1));
|
|
|
|
|
ch.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
|
|
|
|
|
ch.topic = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
|
|
|
|
|
ch.password_protected = sqlite3_column_text(stmt, 4)[0] != '\0';
|
|
|
|
|
ch.max_users = static_cast<uint32_t>(sqlite3_column_int(stmt, 5));
|
|
|
|
|
ch.type = static_cast<voicecat::v1::ChannelType>(sqlite3_column_int(stmt, 6));
|
|
|
|
|
ch.audio = read_audio(stmt, 7);
|
|
|
|
|
ch.sort_order = sqlite3_column_int(stmt, 17);
|
|
|
|
|
result.push_back(std::move(ch));
|
|
|
|
|
}
|
|
|
|
|
sqlite3_finalize(stmt);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::optional<ChannelRecord> Database::get_channel(uint32_t id) {
|
|
|
|
|
sqlite3_stmt* stmt = nullptr;
|
|
|
|
|
sqlite3_prepare_v2(db_, R"sql(
|
|
|
|
|
SELECT id, parent_id, name, topic, password_hash, max_users, type,
|
|
|
|
|
audio_codec, audio_mode, audio_sample_rate, audio_bitrate_bps, audio_frame_ms,
|
|
|
|
|
audio_application, audio_fec, audio_expected_packet_loss, audio_dtx, audio_complexity,
|
|
|
|
|
sort_order
|
|
|
|
|
FROM channels WHERE id=?
|
|
|
|
|
)sql", -1, &stmt, nullptr);
|
|
|
|
|
sqlite3_bind_int(stmt, 1, static_cast<int>(id));
|
|
|
|
|
std::optional<ChannelRecord> result;
|
|
|
|
|
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
|
|
|
|
ChannelRecord ch;
|
|
|
|
|
ch.id = static_cast<uint32_t>(sqlite3_column_int(stmt, 0));
|
|
|
|
|
ch.parent_id = static_cast<uint32_t>(sqlite3_column_int(stmt, 1));
|
|
|
|
|
ch.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
|
|
|
|
|
ch.topic = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
|
|
|
|
|
ch.password_protected = sqlite3_column_text(stmt, 4)[0] != '\0';
|
|
|
|
|
ch.max_users = static_cast<uint32_t>(sqlite3_column_int(stmt, 5));
|
|
|
|
|
ch.type = static_cast<voicecat::v1::ChannelType>(sqlite3_column_int(stmt, 6));
|
|
|
|
|
ch.audio = read_audio(stmt, 7);
|
|
|
|
|
ch.sort_order = sqlite3_column_int(stmt, 17);
|
|
|
|
|
result = std::move(ch);
|
|
|
|
|
}
|
|
|
|
|
sqlite3_finalize(stmt);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string Database::hash_channel_password(const std::string& password) {
|
|
|
|
|
uint8_t salt[16];
|
|
|
|
|
randombytes_buf(salt, sizeof(salt));
|
|
|
|
|
uint8_t hash[32];
|
|
|
|
|
crypto_generichash(hash, sizeof(hash),
|
|
|
|
|
reinterpret_cast<const uint8_t*>(password.data()), password.size(),
|
|
|
|
|
salt, sizeof(salt));
|
|
|
|
|
char salt_hex[33];
|
|
|
|
|
char hash_hex[65];
|
|
|
|
|
sodium_bin2hex(salt_hex, sizeof(salt_hex), salt, sizeof(salt));
|
|
|
|
|
sodium_bin2hex(hash_hex, sizeof(hash_hex), hash, sizeof(hash));
|
|
|
|
|
return std::string(salt_hex) + ":" + hash_hex;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Database::verify_channel_password(const std::string& password, const std::string& stored) {
|
|
|
|
|
auto pos = stored.find(':');
|
|
|
|
|
if (pos == std::string::npos || pos != 32) return false;
|
|
|
|
|
std::string salt_hex = stored.substr(0, pos);
|
|
|
|
|
std::string hash_hex = stored.substr(pos + 1);
|
|
|
|
|
uint8_t salt[16];
|
|
|
|
|
uint8_t expected[32];
|
|
|
|
|
if (sodium_hex2bin(salt, sizeof(salt), salt_hex.c_str(), salt_hex.size(),
|
|
|
|
|
nullptr, nullptr, nullptr) != 0)
|
|
|
|
|
return false;
|
|
|
|
|
if (sodium_hex2bin(expected, sizeof(expected), hash_hex.c_str(), hash_hex.size(),
|
|
|
|
|
nullptr, nullptr, nullptr) != 0)
|
|
|
|
|
return false;
|
|
|
|
|
uint8_t hash[32];
|
|
|
|
|
crypto_generichash(hash, sizeof(hash),
|
|
|
|
|
reinterpret_cast<const uint8_t*>(password.data()), password.size(),
|
|
|
|
|
salt, sizeof(salt));
|
|
|
|
|
return sodium_memcmp(hash, expected, sizeof(hash)) == 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Database::check_channel_password(uint32_t id, const std::string& password) {
|
|
|
|
|
sqlite3_stmt* stmt = nullptr;
|
|
|
|
|
sqlite3_prepare_v2(db_, "SELECT password_hash FROM channels WHERE id=?", -1, &stmt, nullptr);
|
|
|
|
|
sqlite3_bind_int(stmt, 1, static_cast<int>(id));
|
|
|
|
|
bool ok = false;
|
|
|
|
|
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
|
|
|
|
const char* stored = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
|
|
|
|
if (!stored || stored[0] == '\0') {
|
|
|
|
|
ok = true; // no password
|
|
|
|
|
} else {
|
|
|
|
|
ok = verify_channel_password(password, stored);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sqlite3_finalize(stmt);
|
|
|
|
|
return ok;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Bans ───────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
std::optional<BanRecord> Database::ban_create(const std::string& subject_type,
|
|
|
|
|
const std::string& subject,
|
|
|
|
|
const std::string& reason,
|
|
|
|
|
int64_t expires_at,
|
|
|
|
|
std::string& error) {
|
|
|
|
|
if (subject_type != "user_id" && subject_type != "username" && subject_type != "ip") {
|
|
|
|
|
error = "invalid subject_type";
|
|
|
|
|
return std::nullopt;
|
|
|
|
|
}
|
|
|
|
|
if (subject.empty()) { error = "subject must not be empty"; return std::nullopt; }
|
|
|
|
|
|
|
|
|
|
int64_t now = now_unix();
|
|
|
|
|
sqlite3_stmt* stmt = nullptr;
|
|
|
|
|
int rc = sqlite3_prepare_v2(db_,
|
|
|
|
|
"INSERT INTO bans (subject_type, subject, reason, expires_at, created_at) VALUES (?,?,?,?,?)",
|
|
|
|
|
-1, &stmt, nullptr);
|
|
|
|
|
if (rc != SQLITE_OK) { error = sqlite3_errmsg(db_); return std::nullopt; }
|
|
|
|
|
sqlite3_bind_text(stmt, 1, subject_type.c_str(), -1, SQLITE_TRANSIENT);
|
|
|
|
|
sqlite3_bind_text(stmt, 2, subject.c_str(), -1, SQLITE_TRANSIENT);
|
|
|
|
|
sqlite3_bind_text(stmt, 3, reason.c_str(), -1, SQLITE_TRANSIENT);
|
|
|
|
|
sqlite3_bind_int64(stmt, 4, expires_at);
|
|
|
|
|
sqlite3_bind_int64(stmt, 5, now);
|
|
|
|
|
rc = sqlite3_step(stmt);
|
|
|
|
|
sqlite3_finalize(stmt);
|
|
|
|
|
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return std::nullopt; }
|
|
|
|
|
|
|
|
|
|
BanRecord b;
|
|
|
|
|
b.id = sqlite3_last_insert_rowid(db_);
|
|
|
|
|
b.subject_type = subject_type;
|
|
|
|
|
b.subject = subject;
|
|
|
|
|
b.reason = reason;
|
|
|
|
|
b.expires_at = expires_at;
|
|
|
|
|
b.created_at = now;
|
|
|
|
|
return b;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Database::ban_remove(int64_t id, std::string& error) {
|
|
|
|
|
sqlite3_stmt* stmt = nullptr;
|
|
|
|
|
sqlite3_prepare_v2(db_, "DELETE FROM bans WHERE id=?", -1, &stmt, nullptr);
|
|
|
|
|
sqlite3_bind_int64(stmt, 1, id);
|
|
|
|
|
int rc = sqlite3_step(stmt);
|
|
|
|
|
sqlite3_finalize(stmt);
|
|
|
|
|
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
|
|
|
|
|
if (sqlite3_changes(db_) == 0) { error = "ban not found"; return false; }
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Database::ban_check(const std::string& subject_type, const std::string& subject) {
|
|
|
|
|
sqlite3_stmt* stmt = nullptr;
|
|
|
|
|
sqlite3_prepare_v2(db_,
|
|
|
|
|
"SELECT expires_at FROM bans WHERE subject_type=? AND subject=? AND (expires_at=0 OR expires_at>?)",
|
|
|
|
|
-1, &stmt, nullptr);
|
|
|
|
|
sqlite3_bind_text(stmt, 1, subject_type.c_str(), -1, SQLITE_TRANSIENT);
|
|
|
|
|
sqlite3_bind_text(stmt, 2, subject.c_str(), -1, SQLITE_TRANSIENT);
|
|
|
|
|
sqlite3_bind_int64(stmt, 3, now_unix());
|
|
|
|
|
bool banned = false;
|
|
|
|
|
if (sqlite3_step(stmt) == SQLITE_ROW) banned = true;
|
|
|
|
|
sqlite3_finalize(stmt);
|
|
|
|
|
return banned;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::vector<BanRecord> Database::ban_list(bool include_expired) {
|
|
|
|
|
std::vector<BanRecord> result;
|
|
|
|
|
sqlite3_stmt* stmt = nullptr;
|
|
|
|
|
std::string sql = "SELECT id, subject_type, subject, reason, expires_at, created_at FROM bans";
|
|
|
|
|
if (!include_expired) sql += " WHERE expires_at=0 OR expires_at>?";
|
|
|
|
|
sql += " ORDER BY created_at DESC";
|
|
|
|
|
sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr);
|
|
|
|
|
if (!include_expired) sqlite3_bind_int64(stmt, 1, now_unix());
|
|
|
|
|
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
|
|
|
|
BanRecord b;
|
|
|
|
|
b.id = sqlite3_column_int64(stmt, 0);
|
|
|
|
|
b.subject_type = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
|
|
|
|
b.subject = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
|
|
|
|
|
b.reason = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
|
|
|
|
|
b.expires_at = sqlite3_column_int64(stmt, 4);
|
|
|
|
|
b.created_at = sqlite3_column_int64(stmt, 5);
|
|
|
|
|
result.push_back(std::move(b));
|
|
|
|
|
}
|
|
|
|
|
sqlite3_finalize(stmt);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
feat(M1): TCP/TLS control plane -- auth, channels, ephemeral text
Implements the full M1 milestone. Two clients authenticate over TLS 1.3
(guest + Argon2id password) and exchange channel + private text messages
through a real server. All five ctest --preset m1-dev tests pass in ~1 s.
Key components added:
- vcpkg baseline + m1-dev preset (protobuf/mbedTLS/libsodium/asio/sqlite3)
- FrameCodec feed+emit, encode/decode_envelope, protobuf codegen
- TcpServerConn with blocking TLS handshake thread + tls_read_loop
- TlsContext (mbedTLS 1.3, ECDSA-P256 self-signed cert, TOFU on client)
- WorkerPool (3 threads, used for Argon2id)
- Database: SQLite + libsodium Argon2id, account lifecycle, bootstrap admin
- ServerIdentityManager: Ed25519 key + cert generate/persist/fingerprint
- ConnSession state machine: WaitingHello -> WaitingAuth -> Authenticated
- SessionRegistry: channel tree, user map, text routing, broadcast
- vc_client full M1 C ABI: connect/TLS/handshake/auth/text/disconnect
- voicecat-admin CLI: account add/reset/del/list
- test_m1_integration: M1 exit criterion, verified green
Bug fixed: double-framing in ConnSession::send_envelope -- encode_envelope
was adding the [4-byte len] prefix, then TcpServerConn::send_frame added
a second one, causing the client to parse [len][proto] as protobuf (silent
failure). Fixed by serializing raw protobuf bytes in send_envelope and
letting send_frame apply the single length prefix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:48:44 +02:00
|
|
|
std::string Database::generate_password(size_t length) {
|
|
|
|
|
static const char kAlphabet[] =
|
|
|
|
|
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*";
|
|
|
|
|
constexpr size_t kAlphaLen = sizeof(kAlphabet) - 1;
|
|
|
|
|
std::string pw;
|
|
|
|
|
pw.reserve(length);
|
|
|
|
|
for (size_t i = 0; i < length; ++i) {
|
|
|
|
|
uint8_t rnd[1];
|
|
|
|
|
randombytes_buf(rnd, 1);
|
|
|
|
|
pw += kAlphabet[rnd[0] % kAlphaLen];
|
|
|
|
|
}
|
|
|
|
|
return pw;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Database::exec(const std::string& sql, std::string& error) {
|
|
|
|
|
char* errmsg = nullptr;
|
|
|
|
|
int rc = sqlite3_exec(db_, sql.c_str(), nullptr, nullptr, &errmsg);
|
|
|
|
|
if (rc != SQLITE_OK) {
|
|
|
|
|
error = errmsg ? errmsg : "unknown error";
|
|
|
|
|
if (errmsg) sqlite3_free(errmsg);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int64_t Database::now_unix() const {
|
|
|
|
|
using namespace std::chrono;
|
|
|
|
|
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace voicecat::server
|
|
|
|
|
|
|
|
|
|
#endif // VOICECAT_HAS_NET
|