Proof of concept

This commit is contained in:
2025-07-10 21:31:52 +02:00
parent 80c06b9c4e
commit af8bfe1518
8 changed files with 566 additions and 0 deletions

6
packages/myid/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.zig-cache
*.sock
*.sqlite3
*.sqlite3-shm
*.sqlite3-wal
zig-out

63
packages/myid/build.zig Normal file
View File

@@ -0,0 +1,63 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const sqlite_dep = b.dependency("sqlite", .{
.target = target,
.optimize = optimize,
});
const sqlite_mod = sqlite_dep.module("sqlite");
const myid_mod = b.addModule("myid", .{
.root_source_file = b.path("src/root.zig"),
.target = target,
.imports = &.{
.{ .name = "sqlite", .module = sqlite_mod },
},
});
const myid_exe = b.addExecutable(.{
.name = "myid",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "myid", .module = myid_mod },
.{ .name = "sqlite", .module = sqlite_mod },
},
}),
});
b.installArtifact(myid_exe);
const run_step = b.step("run", "Run the app");
const run_cmd = b.addRunArtifact(myid_exe);
run_step.dependOn(&run_cmd.step);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| {
run_cmd.addArgs(args);
}
const mod_tests = b.addTest(.{
.root_module = myid_mod,
});
const run_mod_tests = b.addRunArtifact(mod_tests);
const exe_tests = b.addTest(.{
.root_module = myid_exe.root_module,
});
const run_exe_tests = b.addRunArtifact(exe_tests);
const test_step = b.step("test", "Run tests");
test_step.dependOn(&run_mod_tests.step);
test_step.dependOn(&run_exe_tests.step);
}

View File

@@ -0,0 +1,17 @@
.{
.name = .myid,
.version = "0.0.0",
.dependencies = .{
.sqlite = .{
.url = "git+https://github.com/vrischmann/zig-sqlite#be8b4965b46fc1a7a819bf3cba09f370c0e9c64c",
.hash = "sqlite-3.48.0-F2R_a9GLDgAXT-c49TfkFMt6yPOMQAYfp4ig8bRNdZs4",
},
},
.fingerprint = 0x4dc8d6b2af89dd8f, // Changing this has security and trust implications.
.minimum_zig_version = "0.15.0-dev.919+044ccf413",
.paths = .{
"build.zig",
"build.zig.zon",
"src",
},
}

View File

@@ -0,0 +1,2 @@
pub const database_path = "db.sqlite3";
pub const socket_path = "myid.sock";

View File

@@ -0,0 +1,66 @@
const std = @import("std");
const main = @import("main.zig");
threadlocal var read_buffer: [2 * 1024 * 1024]u8 = undefined;
threadlocal var write_buffer: [2 * 1024 * 1024]u8 = undefined;
const http_400 = "HTTP/1.1 400 Bad Request\r\n\r\n";
const http_404 = "HTTP/1.1 404 Not Found\r\n\r\n";
const http_413 = "HTTP/1.1 413 Content Too Large\r\n\r\n";
const http_431 = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n";
const http_500 = "HTTP/1.1 500 Internal Server Error\r\n\r\n";
const log = std.log.scoped(.http);
fn http_200(content_type: []const u8, response_body: []const u8) ![]const u8 {
var fbs = std.io.fixedBufferStream(&write_buffer);
const writer = fbs.writer();
try writer.print("HTTP/1.1 200 OK\r\n", .{});
try writer.print("Content-Type: {s}\r\n", .{content_type});
try writer.print("Content-Length: {d}\r\n", .{response_body.len});
try writer.print("\r\n", .{});
try writer.print("{s}", .{response_body});
return fbs.getWritten();
}
pub fn process(conn: std.net.Server.Connection) !void {
defer conn.stream.close();
var arena = std.heap.ArenaAllocator.init(main.allocator);
defer arena.deinit();
const allocator = arena.allocator();
_ = allocator;
var running = true;
while (running) {
var header_end: ?usize = null;
var request_len: usize = 0;
const request_head = blk: while (conn.stream.read(read_buffer[request_len..])) |len| {
if (len == 0) {
running = false;
return;
}
header_end = std.mem.indexOfPos(u8, &read_buffer, request_len, "\r\n\r\n");
request_len += len;
if (header_end) |end| {
break :blk read_buffer[0..end];
}
} else |err| {
return err;
};
const response = try http_200("text/plain; charset=utf-8", "PONG\n");
try conn.stream.writeAll(response);
if (request_len > request_head.len + 4) {
@memmove(&read_buffer, read_buffer[request_head.len + 4 .. request_len]);
}
}
}

136
packages/myid/src/main.zig Normal file
View File

@@ -0,0 +1,136 @@
const std = @import("std");
const default = @import("default.zig");
const http = @import("http.zig");
pub var allocator: std.mem.Allocator = undefined;
pub var args: [][:0]u8 = undefined;
pub var env: std.process.EnvMap = undefined;
pub var config: struct {
database_path: []const u8 = default.database_path,
socket_path: []const u8 = default.socket_path,
} = .{};
pub fn main() !void {
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
defer _ = gpa.deinit();
allocator = gpa.allocator();
// --- READ CONFIG ---------------------------------------------------------
args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
env = try std.process.getEnvMap(allocator);
defer env.deinit();
// override config by environment variables
if (env.get("DATABASE_PATH")) |database_path| {
config.database_path = database_path;
}
if (env.get("SOCKET_PATH")) |socket_path| {
config.socket_path = socket_path;
}
// override config by args
var show_help = false;
var arg_i: usize = 1;
while (arg_i < args.len) : (arg_i += 1) {
const arg = args[arg_i];
if (std.mem.eql(u8, arg, "--database-path")) {
if (arg_i < args.len - 1) {
std.log.err("Missing argument for --database-path", .{});
return;
}
arg_i += 1;
config.database_path = args[arg_i];
} else if (std.mem.eql(u8, arg, "--socket-path")) {
if (arg_i < args.len - 1) {
std.log.err("Missing argument for --socket-path", .{});
return;
}
arg_i += 1;
config.socket_path = args[arg_i];
} else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
show_help = true;
} else {
std.log.err("Unrecognized option: {s}", .{arg});
return;
}
}
if (show_help) {
const stderr = std.io.getStdErr().writer();
try stderr.print(
\\Usage: myid [flags]
\\
\\Flags:
\\ --database-path <path>
\\ The path to an SQLite 3 database file. You can specify ":memory:" to use
\\ in-memory ephemeral database.
\\ Default: "{[default_database_path]s}"
\\ --socket-path <path>
\\ The path to a unix socket on which the server will listen.
\\ Default: "{[default_socket_path]s}"
\\
\\Environment variables:
\\ DATABASE_PATH
\\ Equivalent to --database-path flag. When both are present, the flag
\\ takes precedence.
\\ SOCKET_PATH
\\ Equivalent to --socket-path flag. When both are present, the flag takes
\\ precedence.
\\
, .{
.default_database_path = default.database_path,
.default_socket_path = default.socket_path,
});
return;
}
// --- INITIALIZE SERVER ---------------------------------------------------
std.posix.unlink(config.socket_path) catch |err| switch (err) {
error.FileNotFound => {},
else => return err,
};
const address = try std.net.Address.initUnix(config.socket_path);
var server = try address.listen(.{});
defer {
server.deinit();
std.log.info("Server stopped", .{});
}
std.log.info("Listening on unix:{s}", .{config.socket_path});
// --- LOOP ----------------------------------------------------------------
while (server.accept()) |conn| {
const thread = std.Thread.spawn(.{ .allocator = allocator }, process, .{conn}) catch |err| {
std.log.err("Error while spawning thread: {}", .{err});
continue;
};
thread.detach();
} else |err| {
std.log.err("Error while trying to accept connection: {}", .{err});
}
}
fn process(conn: std.net.Server.Connection) void {
http.process(conn) catch |err| {
// TODO Bandaid spam prevention until we manage closing connections properly
if (err == error.ConnectionResetByPeer) {
return;
}
std.log.err("Error while processing connection: {}", .{err});
};
}

235
packages/myid/src/root.zig Normal file
View File

@@ -0,0 +1,235 @@
const std = @import("std");
const sqlite = @import("sqlite");
const uuid = @import("uuid.zig");
fn Id(comptime _tag: @Type(.enum_literal)) type {
return struct {
pub const tag = _tag;
bytes: [16]u8,
pub fn new() @This() {
return .{ .bytes = uuid.uuid_v7() };
}
pub fn eql(a: @This(), b: @This()) bool {
return std.mem.eql(u8, &a.bytes, &b.bytes);
}
pub fn decode(encoded: *const [22]u8) !@This() {
var bytes: [16]u8 = undefined;
try std.base64.url_safe_no_pad.Decoder.decode(&bytes, encoded);
return .{ .bytes = bytes };
}
pub fn encode(self: @This()) [22]u8 {
var text: [22]u8 = undefined;
std.base64.url_safe_no_pad.Encoder.encode(&text, self.bytes);
return text;
}
};
}
pub const AppId = Id(.app_id);
pub const UserId = Id(.user_id);
pub const CreateAppResult = struct {
aid: AppId,
plain_secret: [32]u8,
};
pub const Database = struct {
db: sqlite.Db,
allocator: std.mem.Allocator,
pub fn init(path: [:0]const u8, allocator: std.mem.Allocator) !Database {
var db = try sqlite.Db.init(.{
.mode = .{ .File = path },
.open_flags = .{
.create = true,
.write = true,
},
.threading_mode = .MultiThread,
});
errdefer db.deinit();
_ = try db.one(void, "PRAGMA journal_mode = WAL", .{}, .{});
_ = try db.one(void, "PRAGMA foreign_keys = ON", .{}, .{});
return .{
.db = db,
.allocator = allocator,
};
}
pub fn deinit(self: *Database) void {
self.db.deinit();
self.* = undefined;
}
// --- MIGRATION -----------------------------------------------------------
fn getUserVersion(self: *Database) !i32 {
const version = try self.db.one(i32, "PRAGMA user_version", .{}, .{});
return version.?;
}
fn setUserVersion(self: *Database, version: i32) !void {
var buf: [100]u8 = undefined;
const query = std.fmt.bufPrint(&buf, "PRAGMA user_version = {d}", .{version}) catch unreachable;
_ = try self.db.oneDynamic(void, query, .{}, .{});
}
pub fn migrate(self: *Database) !void {
var user_version = try self.getUserVersion();
if (user_version == 0) {
_ = try self.db.exec(
\\CREATE TABLE users (
\\ uid BLOB NOT NULL,
\\ name TEXT NOT NULL,
\\ email TEXT NOT NULL UNIQUE,
\\ password TEXT NOT NULL,
\\ PRIMARY KEY (uid)
\\)
, .{}, .{});
_ = try self.db.exec(
\\CREATE TABLE apps (
\\ aid BLOB NOT NULL,
\\ name TEXT NOT NULL,
\\ secret TEXT NOT NULL,
\\ PRIMARY KEY (aid)
\\)
, .{}, .{});
_ = try self.db.exec(
\\CREATE TABLE app_callbacks (
\\ aid BLOB NOT NULL,
\\ callback TEXT NOT NULL,
\\ PRIMARY KEY (aid, callback),
\\ FOREIGN KEY (aid) REFERENCES apps (aid)
\\ ON UPDATE CASCADE
\\ ON DELETE CASCADE
\\)
, .{}, .{});
user_version += 1;
try self.setUserVersion(user_version);
}
}
// --- USERS ---------------------------------------------------------------
pub fn createUser(self: *Database, name: []const u8, email: []const u8, plain_password: []const u8) !UserId {
const uid = UserId.new();
var password_buf: [1000]u8 = undefined;
const password = try std.crypto.pwhash.argon2.strHash(plain_password, .{
.allocator = self.allocator,
.mode = .argon2id,
.params = .owasp_2id,
}, &password_buf);
try self.db.exec("INSERT INTO users (uid, name, email, password) VALUES (?, ?, ?, ?)", .{}, .{
.uid = uid.bytes,
.name = name,
.email = email,
.password = password,
});
return uid;
}
// --- APPS ----------------------------------------------------------------
pub fn createApp(self: *Database, name: []const u8, callbacks: []const []const u8) !CreateAppResult {
_ = try self.db.exec("BEGIN", .{}, .{});
errdefer self.db.exec("ROLLBACK", .{}, .{}) catch unreachable;
const secret_bytes = blk: {
var bytes: [24]u8 = undefined;
std.crypto.random.bytes(&bytes);
break :blk bytes;
};
var plain_secret_buf: [32]u8 = undefined;
const plain_secret = std.base64.url_safe_no_pad.Encoder.encode(&plain_secret_buf, &secret_bytes);
std.debug.assert(plain_secret_buf.len == plain_secret.len);
const aid = AppId.new();
var secret_buf: [1000]u8 = undefined;
const secret = try std.crypto.pwhash.argon2.strHash(plain_secret, .{
.allocator = self.allocator,
.mode = .argon2id,
.params = .owasp_2id,
}, &secret_buf);
try self.db.exec("INSERT INTO apps (aid, name, secret) VALUES (?, ?, ?)", .{}, .{
.aid = aid.bytes,
.name = name,
.secret = secret,
});
var insert_callback = try self.db.prepare("INSERT INTO app_callbacks (aid, callback) VALUES (?, ?)");
defer insert_callback.deinit();
for (callbacks) |callback| {
try insert_callback.exec(.{}, .{ .aid = aid.bytes, .callback = callback });
insert_callback.reset();
}
try self.db.exec("COMMIT", .{}, .{});
return .{
.aid = aid,
.plain_secret = plain_secret_buf,
};
}
};
test "user version" {
var db = try Database.init(":memory:", std.testing.allocator);
defer db.deinit();
try std.testing.expectEqual(0, try db.getUserVersion());
try db.setUserVersion(1);
try std.testing.expectEqual(1, try db.getUserVersion());
}
test "migrate" {
var db = try Database.init(":memory:", std.testing.allocator);
defer db.deinit();
try db.migrate();
}
test "create user" {
var db = try Database.init(":memory:", std.testing.allocator);
defer db.deinit();
try db.migrate();
const uid = try db.createUser("admin", "admin@test.invalid", "admin");
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
const maybe_user = try db.db.oneAlloc(struct {
name: []const u8,
email: []const u8,
password: []const u8,
}, arena.allocator(), "SELECT name, email, password FROM users WHERE uid = ?", .{}, .{
.uid = uid.bytes,
});
defer arena.deinit();
try std.testing.expect(maybe_user != null);
if (maybe_user) |user| {
try std.testing.expectEqualSlices(u8, "admin", user.name);
}
}
test "create app" {
var db = try Database.init(":memory:", std.testing.allocator);
defer db.deinit();
try db.migrate();
_ = try db.createApp("app", &.{
"http://localhost:3000/callback",
"https://example.com/callback",
});
}

View File

@@ -0,0 +1,41 @@
const std = @import("std");
var lock: std.Thread.Mutex = .{};
var last_timestamp: std.atomic.Value(u64) = .{ .raw = 0 };
var counter: std.atomic.Value(u32) = .{ .raw = 0 };
fn getCount(timestamp: u64) u32 {
lock.lock();
defer lock.unlock();
if (last_timestamp.swap(timestamp, .monotonic) != timestamp) {
counter.store(0, .monotonic);
}
return counter.fetchAdd(1, .monotonic) % 4096;
}
pub fn uuid_v7() [16]u8 {
const timestamp: u64 = @intCast(@max(0, std.time.milliTimestamp()));
const count = getCount(timestamp);
const random = blk: {
var bytes: [8]u8 = undefined;
std.crypto.random.bytes(&bytes);
break :blk bytes;
};
var res: [16]u8 = undefined;
res[0] = @truncate(timestamp >> 40);
res[1] = @truncate(timestamp >> 32);
res[2] = @truncate(timestamp >> 24);
res[3] = @truncate(timestamp >> 16);
res[4] = @truncate(timestamp >> 8);
res[5] = @truncate(timestamp);
res[6] = (@as(u8, 7) << 4) | @as(u8, @truncate((count >> 8) & 0x0F));
res[7] = @truncate(count);
res[8] = 0x80 | (random[0] & 0x3F);
@memcpy(res[9..16], random[1..8]);
return res;
}