From 76e9c10749f2f2363066baf2b667cce6a275bdff Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 18:56:20 +0100 Subject: [PATCH 01/19] docs: add data model expansion design Comprehensive design for expanding zault from basic credential store to full password manager with: - 9 item types (login, card, identity, SSH key, etc.) - Hybrid custom fields system - Folders and tags organization - Full-text fuzzy search - Automatic password history - Favorites and recents - Attachments support Based on research of 1Password SDK and Bitwarden data models. --- .../2026-01-28-data-model-expansion-design.md | 483 ++++++++++++++++++ 1 file changed, 483 insertions(+) create mode 100644 docs/plans/2026-01-28-data-model-expansion-design.md diff --git a/docs/plans/2026-01-28-data-model-expansion-design.md b/docs/plans/2026-01-28-data-model-expansion-design.md new file mode 100644 index 0000000..61acf67 --- /dev/null +++ b/docs/plans/2026-01-28-data-model-expansion-design.md @@ -0,0 +1,483 @@ +# Data Model Expansion Design + +Date: 2026-01-28 + +## Overview + +Expand zault's data model from a basic credential store to a full-featured password manager comparable to 1Password/Bitwarden. This enables building a native app with rich item types, organization, and search. + +## Goals + +- Support all common item types (login, card, identity, secure note, SSH keys, etc.) +- Hybrid custom fields (typed categories + flexible user fields) +- Organization via folders (hierarchical) and tags (flat, multi-assign) +- Full-text fuzzy search across all fields +- Automatic password history tracking +- Favorites and recents for quick access + +## Non-Goals + +- Cloud sync (future work) +- Sharing/multi-user (future work) +- Browser extension/autofill (future work) +- Biometrics integration (future work) + +--- + +## Core Item Model + +Replace current `Entry` with a richer `Item` structure: + +```zig +pub const Item = struct { + // Identity + id: Uuid, + name: []const u8, + + // Type-specific data + item_type: ItemType, + data: ItemData, + + // Organization + folder_id: ?Uuid, + tags: []const []const u8, + favorite: bool, + + // Custom fields (hybrid approach) + fields: []CustomField, + + // Attachments + attachments: []Attachment, + + // History & audit + password_history: []PasswordHistoryEntry, + created_at: i64, + modified_at: i64, + last_accessed_at: ?i64, + access_count: u32, + + // Soft delete + deleted_at: ?i64, +}; + +pub const ItemType = enum(u8) { + login = 1, + secure_note = 2, + card = 3, + identity = 4, + ssh_key = 5, + api_credential = 6, + database = 7, + wifi = 8, + license = 9, +}; +``` + +--- + +## Item Type Data Structures + +### Login + +```zig +pub const LoginData = struct { + username: ?[]const u8, + password: ?[]const u8, + uris: []Uri, + totp: ?TotpData, + passkeys: []PasskeyData, +}; + +pub const Uri = struct { + uri: []const u8, + match_type: UriMatchType, +}; + +pub const UriMatchType = enum(u8) { + domain = 0, + host = 1, + starts_with = 2, + exact = 3, + regex = 4, + never = 5, +}; +``` + +### Card + +```zig +pub const CardData = struct { + cardholder_name: ?[]const u8, + number: ?[]const u8, + brand: ?CardBrand, + exp_month: ?u8, + exp_year: ?u16, + cvv: ?[]const u8, + pin: ?[]const u8, +}; + +pub const CardBrand = enum(u8) { + visa = 1, + mastercard = 2, + amex = 3, + discover = 4, + diners = 5, + jcb = 6, + unionpay = 7, + other = 255, +}; +``` + +### Identity + +```zig +pub const IdentityData = struct { + title: ?[]const u8, + first_name: ?[]const u8, + middle_name: ?[]const u8, + last_name: ?[]const u8, + email: ?[]const u8, + phone: ?[]const u8, + address: ?AddressData, + ssn: ?[]const u8, + passport: ?[]const u8, + license_number: ?[]const u8, + company: ?[]const u8, + job_title: ?[]const u8, +}; + +pub const AddressData = struct { + street1: ?[]const u8, + street2: ?[]const u8, + city: ?[]const u8, + state: ?[]const u8, + postal_code: ?[]const u8, + country: ?[]const u8, +}; +``` + +### SSH Key + +```zig +pub const SshKeyData = struct { + private_key: []const u8, + public_key: []const u8, + fingerprint: []const u8, + key_type: SshKeyType, + passphrase: ?[]const u8, +}; + +pub const SshKeyType = enum(u8) { + ed25519 = 1, + rsa = 2, + ecdsa = 3, + dsa = 4, +}; +``` + +### Secure Note + +```zig +pub const SecureNoteData = struct { + // Notes stored in Item.notes field + // This struct exists for type consistency +}; +``` + +### API Credential + +```zig +pub const ApiCredentialData = struct { + api_key: ?[]const u8, + api_secret: ?[]const u8, + endpoint: ?[]const u8, + documentation_url: ?[]const u8, +}; +``` + +### Database + +```zig +pub const DatabaseData = struct { + db_type: DatabaseType, + host: ?[]const u8, + port: ?u16, + database: ?[]const u8, + username: ?[]const u8, + password: ?[]const u8, + connection_string: ?[]const u8, + sid: ?[]const u8, +}; + +pub const DatabaseType = enum(u8) { + postgresql = 1, + mysql = 2, + mariadb = 3, + sqlite = 4, + mongodb = 5, + redis = 6, + oracle = 7, + sqlserver = 8, + other = 255, +}; +``` + +### WiFi + +```zig +pub const WifiData = struct { + ssid: []const u8, + password: ?[]const u8, + security: WifiSecurity, + hidden: bool, +}; + +pub const WifiSecurity = enum(u8) { + none = 0, + wep = 1, + wpa = 2, + wpa2 = 3, + wpa3 = 4, +}; +``` + +### License + +```zig +pub const LicenseData = struct { + license_key: ?[]const u8, + product_name: ?[]const u8, + version: ?[]const u8, + publisher: ?[]const u8, + email: ?[]const u8, + purchase_date: ?i64, + expiration_date: ?i64, +}; +``` + +--- + +## Custom Fields + +```zig +pub const CustomField = struct { + name: []const u8, + value: ?[]const u8, + field_type: FieldType, + linked_id: ?LinkedFieldId, +}; + +pub const FieldType = enum(u8) { + text = 0, + hidden = 1, + boolean = 2, + url = 3, + email = 4, + date = 5, + month_year = 6, + phone = 7, + totp = 8, + linked = 9, +}; + +pub const LinkedFieldId = enum(u8) { + // Login + username = 1, + password = 2, + + // Card + cardholder = 3, + card_number = 4, + exp_month = 5, + exp_year = 6, + cvv = 7, + + // Identity + full_name = 8, + email = 9, + phone = 10, + address = 11, +}; +``` + +--- + +## Organization + +### Folders + +```zig +pub const Folder = struct { + id: Uuid, + name: []const u8, + parent_id: ?Uuid, + created_at: i64, + modified_at: i64, +}; +``` + +### Tags + +```zig +pub const Tag = struct { + id: Uuid, + name: []const u8, + color: ?[]const u8, +}; +``` + +--- + +## Vault Structure + +```zig +pub const Vault = struct { + allocator: std.mem.Allocator, + header: VaultHeader, + + items: std.ArrayList(Item), + folders: std.ArrayList(Folder), + tags: std.ArrayList(Tag), + + // Indexes + items_by_folder: std.AutoHashMap(Uuid, std.ArrayList(*Item)), + items_by_tag: std.AutoHashMap(Uuid, std.ArrayList(*Item)), + + // Quick access + recent_items: RingBuffer(*Item, 20), + + // Search + search_index: SearchIndex, + + // State + is_locked: bool, + path: []const u8, + derived_key: ?[32]u8, + + // Methods + pub fn getItemsByFolder(self: *Self, folder_id: ?Uuid) []const *Item; + pub fn getItemsByTag(self: *Self, tag_id: Uuid) []const *Item; + pub fn getFavorites(self: *Self) []const *Item; + pub fn getRecents(self: *Self) []const *Item; + pub fn getDeleted(self: *Self) []const *Item; + pub fn search(self: *Self, query: []const u8) ![]const *Item; + + pub fn addItem(self: *Self, item: Item) !void; + pub fn updateItem(self: *Self, item: *Item) !void; + pub fn deleteItem(self: *Self, item_id: Uuid, permanent: bool) !void; + pub fn restoreItem(self: *Self, item_id: Uuid) !void; +}; +``` + +--- + +## Search + +Trigram-based fuzzy search index: + +```zig +pub const SearchIndex = struct { + allocator: std.mem.Allocator, + trigrams: std.StringHashMap(std.ArrayList(Uuid)), + + pub fn index(self: *Self, item: *const Item) !void; + pub fn remove(self: *Self, item_id: Uuid) void; + pub fn search(self: *Self, query: []const u8, limit: usize) ![]const Uuid; + pub fn rebuild(self: *Self, items: []const Item) !void; +}; +``` + +Indexed fields per item type: +- All: name, notes, custom field names/values (non-hidden), tags +- Login: username, URI hosts +- Card: cardholder name, last 4 digits +- Identity: all name fields, email, company +- SSH Key: fingerprint, key type +- Database: host, database name +- WiFi: SSID + +--- + +## Password History + +```zig +pub const PasswordHistoryEntry = struct { + password: []const u8, + changed_at: i64, +}; +``` + +Behavior: +- Automatically saved when password field changes +- Keep last 10 entries per item +- Stored encrypted alongside other sensitive data +- Applies to: login password, card PIN, SSH passphrase, database password, WiFi password + +--- + +## Attachments + +```zig +pub const Attachment = struct { + id: Uuid, + filename: []const u8, + size: u64, + mime_type: ?[]const u8, + key: [32]u8, // Per-attachment encryption key + created_at: i64, +}; +``` + +Attachments stored as separate encrypted files in vault directory, referenced by ID. + +--- + +## Migration Path + +1. Bump vault version from 1 to 2 +2. On open, detect version and migrate: + - `password` entries become `login` items + - `totp` entries become `login` items with TOTP field + - `passkey` entries become `login` items with passkeys array +3. New serializer handles both reading v1 (with migration) and v2 + +--- + +## Implementation Phases + +### Phase 1: Core Data Model +- New item types and structures +- Updated serializer (v2 format) +- Migration from v1 +- Basic CRUD operations + +### Phase 2: Organization +- Folders (nested) +- Tags +- Favorites +- Recents tracking + +### Phase 3: Search +- Trigram index implementation +- Full-text search across fields +- Fuzzy matching + +### Phase 4: CLI Updates +- New commands for item types +- Folder/tag management +- Search command + +### Phase 5: Attachments +- File storage +- Per-attachment encryption +- Size limits + +--- + +## Security Considerations + +- All sensitive fields (passwords, keys, SSN, card numbers) stored encrypted +- Custom fields with `hidden` type treated as sensitive +- Password history encrypted +- Attachments encrypted with per-file keys +- Search index only contains non-sensitive data From 019c18907f3feeb8a62921f094de6d02af3ee903 Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:00:29 +0100 Subject: [PATCH 02/19] docs: add Phase 1 implementation plan for core data model --- .../2026-01-28-phase1-core-data-model.md | 1979 +++++++++++++++++ 1 file changed, 1979 insertions(+) create mode 100644 docs/plans/2026-01-28-phase1-core-data-model.md diff --git a/docs/plans/2026-01-28-phase1-core-data-model.md b/docs/plans/2026-01-28-phase1-core-data-model.md new file mode 100644 index 0000000..c24f63d --- /dev/null +++ b/docs/plans/2026-01-28-phase1-core-data-model.md @@ -0,0 +1,1979 @@ +# Phase 1: Core Data Model Implementation + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the current 3-type Entry system with a 9-type Item system supporting the full password manager data model. + +**Architecture:** New `src/core/item.zig` defines all item types. New `src/core/serializer_v2.zig` handles the new format. The vault header version bumps from 1 to 2. On load, v1 vaults are migrated to v2 in memory and saved in v2 format. + +**Tech Stack:** Zig 0.15, no new dependencies. + +--- + +## Task 1: Create UUID Type + +**Files:** +- Create: `src/core/uuid.zig` +- Test: `src/core/uuid.zig` (inline tests) + +**Step 1: Write the failing test** + +Create `src/core/uuid.zig` with test at bottom: + +```zig +const std = @import("std"); + +pub const Uuid = struct { + bytes: [16]u8, + + pub fn generate() Uuid { + var bytes: [16]u8 = undefined; + std.crypto.random.bytes(&bytes); + // Set version 4 (random) and variant bits + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + return .{ .bytes = bytes }; + } + + pub fn fromBytes(bytes: [16]u8) Uuid { + return .{ .bytes = bytes }; + } + + pub fn eql(self: Uuid, other: Uuid) bool { + return std.mem.eql(u8, &self.bytes, &other.bytes); + } + + pub fn isZero(self: Uuid) bool { + return std.mem.eql(u8, &self.bytes, &[_]u8{0} ** 16); + } + + pub const zero = Uuid{ .bytes = [_]u8{0} ** 16 }; +}; + +test "uuid generate produces unique values" { + const a = Uuid.generate(); + const b = Uuid.generate(); + try std.testing.expect(!a.eql(b)); +} + +test "uuid zero check" { + const z = Uuid.zero; + try std.testing.expect(z.isZero()); + + const g = Uuid.generate(); + try std.testing.expect(!g.isZero()); +} + +test "uuid version 4 format" { + const u = Uuid.generate(); + // Version should be 4 + try std.testing.expectEqual(@as(u8, 4), (u.bytes[6] >> 4) & 0x0f); + // Variant should be 10xx + try std.testing.expectEqual(@as(u8, 2), (u.bytes[8] >> 6) & 0x03); +} +``` + +**Step 2: Run test to verify it passes** + +Run: `zig build test 2>&1 | grep -E "(uuid|PASS|FAIL)"` +Expected: All 3 uuid tests PASS + +**Step 3: Commit** + +```bash +git add src/core/uuid.zig +git commit -m "feat(core): add UUID type for item identifiers" +``` + +--- + +## Task 2: Create Item Types Enum and Base Structures + +**Files:** +- Create: `src/core/item.zig` +- Test: `src/core/item.zig` (inline tests) + +**Step 1: Create item.zig with enums and base Item struct** + +```zig +const std = @import("std"); +const Uuid = @import("uuid.zig").Uuid; + +pub const ItemType = enum(u8) { + login = 1, + secure_note = 2, + card = 3, + identity = 4, + ssh_key = 5, + api_credential = 6, + database = 7, + wifi = 8, + license = 9, +}; + +pub const FieldType = enum(u8) { + text = 0, + hidden = 1, + boolean = 2, + url = 3, + email = 4, + date = 5, + month_year = 6, + phone = 7, + totp = 8, + linked = 9, +}; + +pub const CustomField = struct { + name: []const u8, + value: ?[]const u8, + field_type: FieldType, + + pub fn deinit(self: *CustomField, allocator: std.mem.Allocator) void { + allocator.free(self.name); + if (self.value) |v| allocator.free(v); + } +}; + +pub const PasswordHistoryEntry = struct { + password: []const u8, + changed_at: i64, + + pub fn deinit(self: *PasswordHistoryEntry, allocator: std.mem.Allocator) void { + @memset(@constCast(self.password), 0); + allocator.free(self.password); + } +}; + +pub const Item = struct { + id: Uuid, + name: []const u8, + notes: ?[]const u8, + item_type: ItemType, + data: ItemData, + favorite: bool, + fields: []CustomField, + password_history: []PasswordHistoryEntry, + created_at: i64, + modified_at: i64, + last_accessed_at: ?i64, + access_count: u32, + deleted_at: ?i64, + + const Self = @This(); + + pub fn deinit(self: *Self, allocator: std.mem.Allocator) void { + allocator.free(self.name); + if (self.notes) |n| allocator.free(n); + + for (self.fields) |*f| f.deinit(allocator); + allocator.free(self.fields); + + for (self.password_history) |*h| h.deinit(allocator); + allocator.free(self.password_history); + + self.data.deinit(allocator); + } + + pub fn updateLastAccessed(self: *Self) void { + self.last_accessed_at = std.time.timestamp(); + self.access_count += 1; + } +}; + +pub const ItemData = union(ItemType) { + login: LoginData, + secure_note: SecureNoteData, + card: CardData, + identity: IdentityData, + ssh_key: SshKeyData, + api_credential: ApiCredentialData, + database: DatabaseData, + wifi: WifiData, + license: LicenseData, + + pub fn deinit(self: *ItemData, allocator: std.mem.Allocator) void { + switch (self.*) { + .login => |*d| d.deinit(allocator), + .secure_note => {}, + .card => |*d| d.deinit(allocator), + .identity => |*d| d.deinit(allocator), + .ssh_key => |*d| d.deinit(allocator), + .api_credential => |*d| d.deinit(allocator), + .database => |*d| d.deinit(allocator), + .wifi => |*d| d.deinit(allocator), + .license => |*d| d.deinit(allocator), + } + } +}; + +// Placeholder structs - will be filled in next tasks +pub const LoginData = struct { + username: ?[]const u8 = null, + password: ?[]const u8 = null, + + pub fn deinit(self: *LoginData, allocator: std.mem.Allocator) void { + if (self.username) |u| allocator.free(u); + if (self.password) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + } + } +}; + +pub const SecureNoteData = struct {}; + +pub const CardData = struct { + pub fn deinit(_: *CardData, _: std.mem.Allocator) void {} +}; + +pub const IdentityData = struct { + pub fn deinit(_: *IdentityData, _: std.mem.Allocator) void {} +}; + +pub const SshKeyData = struct { + pub fn deinit(_: *SshKeyData, _: std.mem.Allocator) void {} +}; + +pub const ApiCredentialData = struct { + pub fn deinit(_: *ApiCredentialData, _: std.mem.Allocator) void {} +}; + +pub const DatabaseData = struct { + pub fn deinit(_: *DatabaseData, _: std.mem.Allocator) void {} +}; + +pub const WifiData = struct { + pub fn deinit(_: *WifiData, _: std.mem.Allocator) void {} +}; + +pub const LicenseData = struct { + pub fn deinit(_: *LicenseData, _: std.mem.Allocator) void {} +}; + +test "item type enum values" { + try std.testing.expectEqual(@as(u8, 1), @intFromEnum(ItemType.login)); + try std.testing.expectEqual(@as(u8, 9), @intFromEnum(ItemType.license)); +} + +test "field type enum values" { + try std.testing.expectEqual(@as(u8, 0), @intFromEnum(FieldType.text)); + try std.testing.expectEqual(@as(u8, 1), @intFromEnum(FieldType.hidden)); +} +``` + +**Step 2: Run test to verify it passes** + +Run: `zig build test 2>&1 | grep -E "(item|PASS|FAIL)"` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/core/item.zig +git commit -m "feat(core): add Item type with enums and base structures" +``` + +--- + +## Task 3: Implement LoginData with Full Fields + +**Files:** +- Modify: `src/core/item.zig` + +**Step 1: Write test for LoginData** + +Add to `src/core/item.zig`: + +```zig +test "login data deinit clears password" { + const allocator = std.testing.allocator; + + var login = LoginData{ + .username = try allocator.dupe(u8, "user"), + .password = try allocator.dupe(u8, "secret"), + .uris = &.{}, + .totp = null, + .passkeys = &.{}, + }; + + login.deinit(allocator); + // If we get here without crash, deinit worked +} +``` + +**Step 2: Implement full LoginData** + +Replace the placeholder LoginData with: + +```zig +pub const TotpAlgorithm = enum(u8) { + sha1 = 1, + sha256 = 2, + sha512 = 3, +}; + +pub const TotpData = struct { + secret: []const u8, + algorithm: TotpAlgorithm = .sha1, + digits: u8 = 6, + period: u32 = 30, + + pub fn deinit(self: *TotpData, allocator: std.mem.Allocator) void { + @memset(@constCast(self.secret), 0); + allocator.free(self.secret); + } +}; + +pub const UriMatchType = enum(u8) { + domain = 0, + host = 1, + starts_with = 2, + exact = 3, + regex = 4, + never = 5, +}; + +pub const Uri = struct { + uri: []const u8, + match_type: UriMatchType = .domain, + + pub fn deinit(self: *Uri, allocator: std.mem.Allocator) void { + allocator.free(self.uri); + } +}; + +pub const PasskeyAlgorithm = enum(i32) { + es256 = -7, + ed25519 = -8, + rs256 = -257, +}; + +pub const PasskeyData = struct { + credential_id: []const u8, + private_key: []const u8, + public_key: []const u8, + algorithm: PasskeyAlgorithm, + rp_id: []const u8, + rp_name: ?[]const u8, + user_handle: []const u8, + user_name: ?[]const u8, + counter: u32, + + pub fn deinit(self: *PasskeyData, allocator: std.mem.Allocator) void { + allocator.free(self.credential_id); + @memset(@constCast(self.private_key), 0); + allocator.free(self.private_key); + allocator.free(self.public_key); + allocator.free(self.rp_id); + if (self.rp_name) |n| allocator.free(n); + allocator.free(self.user_handle); + if (self.user_name) |n| allocator.free(n); + } +}; + +pub const LoginData = struct { + username: ?[]const u8 = null, + password: ?[]const u8 = null, + uris: []Uri = &.{}, + totp: ?TotpData = null, + passkeys: []PasskeyData = &.{}, + + pub fn deinit(self: *LoginData, allocator: std.mem.Allocator) void { + if (self.username) |u| allocator.free(u); + if (self.password) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + } + for (self.uris) |*uri| uri.deinit(allocator); + if (self.uris.len > 0) allocator.free(self.uris); + if (self.totp) |*t| { + var totp = t; + totp.deinit(allocator); + } + for (self.passkeys) |*pk| pk.deinit(allocator); + if (self.passkeys.len > 0) allocator.free(self.passkeys); + } + + pub fn hasTotp(self: *const LoginData) bool { + return self.totp != null; + } +}; +``` + +**Step 3: Run test** + +Run: `zig build test 2>&1 | grep -E "(login|PASS|FAIL)"` +Expected: PASS + +**Step 4: Commit** + +```bash +git add src/core/item.zig +git commit -m "feat(core): implement full LoginData with URIs, TOTP, and passkeys" +``` + +--- + +## Task 4: Implement CardData + +**Files:** +- Modify: `src/core/item.zig` + +**Step 1: Write test** + +```zig +test "card data deinit clears sensitive fields" { + const allocator = std.testing.allocator; + + var card = CardData{ + .cardholder_name = try allocator.dupe(u8, "John Doe"), + .number = try allocator.dupe(u8, "4111111111111111"), + .cvv = try allocator.dupe(u8, "123"), + .pin = try allocator.dupe(u8, "1234"), + }; + + card.deinit(allocator); +} +``` + +**Step 2: Implement CardData** + +```zig +pub const CardBrand = enum(u8) { + visa = 1, + mastercard = 2, + amex = 3, + discover = 4, + diners = 5, + jcb = 6, + unionpay = 7, + other = 255, +}; + +pub const CardData = struct { + cardholder_name: ?[]const u8 = null, + number: ?[]const u8 = null, + brand: ?CardBrand = null, + exp_month: ?u8 = null, + exp_year: ?u16 = null, + cvv: ?[]const u8 = null, + pin: ?[]const u8 = null, + + pub fn deinit(self: *CardData, allocator: std.mem.Allocator) void { + if (self.cardholder_name) |n| allocator.free(n); + if (self.number) |n| { + @memset(@constCast(n), 0); + allocator.free(n); + } + if (self.cvv) |c| { + @memset(@constCast(c), 0); + allocator.free(c); + } + if (self.pin) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + } + } + + pub fn lastFour(self: *const CardData) ?[]const u8 { + if (self.number) |n| { + if (n.len >= 4) return n[n.len - 4 ..]; + } + return null; + } +}; +``` + +**Step 3: Run test** + +Run: `zig build test 2>&1 | grep -E "(card|PASS|FAIL)"` +Expected: PASS + +**Step 4: Commit** + +```bash +git add src/core/item.zig +git commit -m "feat(core): implement CardData with brand and secure field handling" +``` + +--- + +## Task 5: Implement IdentityData + +**Files:** +- Modify: `src/core/item.zig` + +**Step 1: Write test** + +```zig +test "identity data full name" { + var identity = IdentityData{ + .first_name = "John", + .middle_name = "Q", + .last_name = "Public", + }; + + const full = identity.fullName(std.testing.allocator) catch unreachable; + defer std.testing.allocator.free(full); + try std.testing.expectEqualStrings("John Q Public", full); +} +``` + +**Step 2: Implement IdentityData** + +```zig +pub const AddressData = struct { + street1: ?[]const u8 = null, + street2: ?[]const u8 = null, + city: ?[]const u8 = null, + state: ?[]const u8 = null, + postal_code: ?[]const u8 = null, + country: ?[]const u8 = null, + + pub fn deinit(self: *AddressData, allocator: std.mem.Allocator) void { + if (self.street1) |s| allocator.free(s); + if (self.street2) |s| allocator.free(s); + if (self.city) |c| allocator.free(c); + if (self.state) |s| allocator.free(s); + if (self.postal_code) |p| allocator.free(p); + if (self.country) |c| allocator.free(c); + } +}; + +pub const IdentityData = struct { + title: ?[]const u8 = null, + first_name: ?[]const u8 = null, + middle_name: ?[]const u8 = null, + last_name: ?[]const u8 = null, + email: ?[]const u8 = null, + phone: ?[]const u8 = null, + address: ?AddressData = null, + ssn: ?[]const u8 = null, + passport: ?[]const u8 = null, + license_number: ?[]const u8 = null, + company: ?[]const u8 = null, + job_title: ?[]const u8 = null, + + pub fn deinit(self: *IdentityData, allocator: std.mem.Allocator) void { + if (self.title) |t| allocator.free(t); + if (self.first_name) |n| allocator.free(n); + if (self.middle_name) |n| allocator.free(n); + if (self.last_name) |n| allocator.free(n); + if (self.email) |e| allocator.free(e); + if (self.phone) |p| allocator.free(p); + if (self.address) |*a| { + var addr = a; + addr.deinit(allocator); + } + if (self.ssn) |s| { + @memset(@constCast(s), 0); + allocator.free(s); + } + if (self.passport) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + } + if (self.license_number) |l| allocator.free(l); + if (self.company) |c| allocator.free(c); + if (self.job_title) |j| allocator.free(j); + } + + pub fn fullName(self: *const IdentityData, allocator: std.mem.Allocator) ![]const u8 { + var parts: std.ArrayList([]const u8) = .empty; + defer parts.deinit(allocator); + + if (self.first_name) |n| try parts.append(allocator, n); + if (self.middle_name) |n| try parts.append(allocator, n); + if (self.last_name) |n| try parts.append(allocator, n); + + return std.mem.join(allocator, " ", parts.items); + } +}; +``` + +**Step 3: Run test** + +Run: `zig build test 2>&1 | grep -E "(identity|PASS|FAIL)"` +Expected: PASS + +**Step 4: Commit** + +```bash +git add src/core/item.zig +git commit -m "feat(core): implement IdentityData with address and sensitive field handling" +``` + +--- + +## Task 6: Implement Remaining Data Types + +**Files:** +- Modify: `src/core/item.zig` + +**Step 1: Implement SshKeyData, ApiCredentialData, DatabaseData, WifiData, LicenseData** + +```zig +pub const SshKeyType = enum(u8) { + ed25519 = 1, + rsa = 2, + ecdsa = 3, + dsa = 4, +}; + +pub const SshKeyData = struct { + private_key: []const u8, + public_key: []const u8, + fingerprint: []const u8, + key_type: SshKeyType, + passphrase: ?[]const u8 = null, + + pub fn deinit(self: *SshKeyData, allocator: std.mem.Allocator) void { + @memset(@constCast(self.private_key), 0); + allocator.free(self.private_key); + allocator.free(self.public_key); + allocator.free(self.fingerprint); + if (self.passphrase) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + } + } +}; + +pub const ApiCredentialData = struct { + api_key: ?[]const u8 = null, + api_secret: ?[]const u8 = null, + endpoint: ?[]const u8 = null, + documentation_url: ?[]const u8 = null, + + pub fn deinit(self: *ApiCredentialData, allocator: std.mem.Allocator) void { + if (self.api_key) |k| { + @memset(@constCast(k), 0); + allocator.free(k); + } + if (self.api_secret) |s| { + @memset(@constCast(s), 0); + allocator.free(s); + } + if (self.endpoint) |e| allocator.free(e); + if (self.documentation_url) |d| allocator.free(d); + } +}; + +pub const DatabaseType = enum(u8) { + postgresql = 1, + mysql = 2, + mariadb = 3, + sqlite = 4, + mongodb = 5, + redis = 6, + oracle = 7, + sqlserver = 8, + other = 255, +}; + +pub const DatabaseData = struct { + db_type: DatabaseType = .postgresql, + host: ?[]const u8 = null, + port: ?u16 = null, + database: ?[]const u8 = null, + username: ?[]const u8 = null, + password: ?[]const u8 = null, + connection_string: ?[]const u8 = null, + sid: ?[]const u8 = null, + + pub fn deinit(self: *DatabaseData, allocator: std.mem.Allocator) void { + if (self.host) |h| allocator.free(h); + if (self.database) |d| allocator.free(d); + if (self.username) |u| allocator.free(u); + if (self.password) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + } + if (self.connection_string) |c| { + @memset(@constCast(c), 0); + allocator.free(c); + } + if (self.sid) |s| allocator.free(s); + } +}; + +pub const WifiSecurity = enum(u8) { + none = 0, + wep = 1, + wpa = 2, + wpa2 = 3, + wpa3 = 4, +}; + +pub const WifiData = struct { + ssid: []const u8, + password: ?[]const u8 = null, + security: WifiSecurity = .wpa2, + hidden: bool = false, + + pub fn deinit(self: *WifiData, allocator: std.mem.Allocator) void { + allocator.free(self.ssid); + if (self.password) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + } + } +}; + +pub const LicenseData = struct { + license_key: ?[]const u8 = null, + product_name: ?[]const u8 = null, + version: ?[]const u8 = null, + publisher: ?[]const u8 = null, + email: ?[]const u8 = null, + purchase_date: ?i64 = null, + expiration_date: ?i64 = null, + + pub fn deinit(self: *LicenseData, allocator: std.mem.Allocator) void { + if (self.license_key) |k| { + @memset(@constCast(k), 0); + allocator.free(k); + } + if (self.product_name) |p| allocator.free(p); + if (self.version) |v| allocator.free(v); + if (self.publisher) |p| allocator.free(p); + if (self.email) |e| allocator.free(e); + } +}; +``` + +**Step 2: Add tests** + +```zig +test "ssh key data deinit clears private key" { + const allocator = std.testing.allocator; + + var ssh = SshKeyData{ + .private_key = try allocator.dupe(u8, "PRIVATE"), + .public_key = try allocator.dupe(u8, "PUBLIC"), + .fingerprint = try allocator.dupe(u8, "SHA256:xxx"), + .key_type = .ed25519, + .passphrase = try allocator.dupe(u8, "secret"), + }; + + ssh.deinit(allocator); +} + +test "wifi data deinit" { + const allocator = std.testing.allocator; + + var wifi = WifiData{ + .ssid = try allocator.dupe(u8, "MyNetwork"), + .password = try allocator.dupe(u8, "wifipass"), + .security = .wpa3, + }; + + wifi.deinit(allocator); +} +``` + +**Step 3: Run tests** + +Run: `zig build test 2>&1 | grep -E "(ssh|wifi|PASS|FAIL)"` +Expected: PASS + +**Step 4: Commit** + +```bash +git add src/core/item.zig +git commit -m "feat(core): implement SshKeyData, ApiCredentialData, DatabaseData, WifiData, LicenseData" +``` + +--- + +## Task 7: Create Item Factory Functions + +**Files:** +- Modify: `src/core/item.zig` + +**Step 1: Write test** + +```zig +test "create login item" { + const allocator = std.testing.allocator; + + var item = try createLoginItem(allocator, "github.com", "user", "pass", "https://github.com"); + defer item.deinit(allocator); + + try std.testing.expectEqualStrings("github.com", item.name); + try std.testing.expectEqual(ItemType.login, item.item_type); + try std.testing.expectEqualStrings("user", item.data.login.username.?); +} +``` + +**Step 2: Implement factory functions** + +```zig +pub fn createLoginItem( + allocator: std.mem.Allocator, + name: []const u8, + username: ?[]const u8, + password: ?[]const u8, + url: ?[]const u8, +) !Item { + const now = std.time.timestamp(); + + const name_copy = try allocator.dupe(u8, name); + errdefer allocator.free(name_copy); + + const username_copy = if (username) |u| try allocator.dupe(u8, u) else null; + errdefer if (username_copy) |u| allocator.free(u); + + const password_copy = if (password) |p| try allocator.dupe(u8, p) else null; + errdefer if (password_copy) |p| allocator.free(p); + + var uris: []Uri = &.{}; + if (url) |u| { + const uri_copy = try allocator.dupe(u8, u); + errdefer allocator.free(uri_copy); + + uris = try allocator.alloc(Uri, 1); + uris[0] = .{ .uri = uri_copy }; + } + + return Item{ + .id = Uuid.generate(), + .name = name_copy, + .notes = null, + .item_type = .login, + .data = .{ + .login = .{ + .username = username_copy, + .password = password_copy, + .uris = uris, + }, + }, + .favorite = false, + .fields = &.{}, + .password_history = &.{}, + .created_at = now, + .modified_at = now, + .last_accessed_at = null, + .access_count = 0, + .deleted_at = null, + }; +} + +pub fn createSecureNoteItem( + allocator: std.mem.Allocator, + name: []const u8, + notes: []const u8, +) !Item { + const now = std.time.timestamp(); + + const name_copy = try allocator.dupe(u8, name); + errdefer allocator.free(name_copy); + + const notes_copy = try allocator.dupe(u8, notes); + errdefer allocator.free(notes_copy); + + return Item{ + .id = Uuid.generate(), + .name = name_copy, + .notes = notes_copy, + .item_type = .secure_note, + .data = .{ .secure_note = .{} }, + .favorite = false, + .fields = &.{}, + .password_history = &.{}, + .created_at = now, + .modified_at = now, + .last_accessed_at = null, + .access_count = 0, + .deleted_at = null, + }; +} + +pub fn createCardItem( + allocator: std.mem.Allocator, + name: []const u8, + cardholder: ?[]const u8, + number: ?[]const u8, + exp_month: ?u8, + exp_year: ?u16, + cvv: ?[]const u8, +) !Item { + const now = std.time.timestamp(); + + const name_copy = try allocator.dupe(u8, name); + errdefer allocator.free(name_copy); + + const cardholder_copy = if (cardholder) |c| try allocator.dupe(u8, c) else null; + errdefer if (cardholder_copy) |c| allocator.free(c); + + const number_copy = if (number) |n| try allocator.dupe(u8, n) else null; + errdefer if (number_copy) |n| allocator.free(n); + + const cvv_copy = if (cvv) |c| try allocator.dupe(u8, c) else null; + errdefer if (cvv_copy) |c| allocator.free(c); + + return Item{ + .id = Uuid.generate(), + .name = name_copy, + .notes = null, + .item_type = .card, + .data = .{ + .card = .{ + .cardholder_name = cardholder_copy, + .number = number_copy, + .exp_month = exp_month, + .exp_year = exp_year, + .cvv = cvv_copy, + }, + }, + .favorite = false, + .fields = &.{}, + .password_history = &.{}, + .created_at = now, + .modified_at = now, + .last_accessed_at = null, + .access_count = 0, + .deleted_at = null, + }; +} +``` + +**Step 3: Run tests** + +Run: `zig build test 2>&1 | grep -E "(create|PASS|FAIL)"` +Expected: PASS + +**Step 4: Commit** + +```bash +git add src/core/item.zig +git commit -m "feat(core): add item factory functions for login, secure note, and card" +``` + +--- + +## Task 8: Create Serializer V2 + +**Files:** +- Create: `src/core/serializer_v2.zig` + +**Step 1: Create serializer with basic structure** + +```zig +const std = @import("std"); +const item = @import("item.zig"); +const Uuid = @import("uuid.zig").Uuid; + +pub const SerializeError = error{ + OutOfMemory, +}; + +pub const DeserializeError = error{ + InvalidData, + UnexpectedEndOfData, + InvalidItemType, + OutOfMemory, +}; + +pub fn serializeItems(allocator: std.mem.Allocator, items: []const item.Item) ![]u8 { + var buffer: std.ArrayList(u8) = .empty; + errdefer buffer.deinit(allocator); + + var writer = buffer.writer(allocator); + + // Write item count + try writer.writeInt(u32, @intCast(items.len), .little); + + for (items) |i| { + try serializeItem(allocator, &buffer, i); + } + + return buffer.toOwnedSlice(allocator); +} + +fn serializeItem(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), i: item.Item) !void { + var writer = buffer.writer(allocator); + + // UUID + try buffer.appendSlice(allocator, &i.id.bytes); + + // Name and notes + try writeString(allocator, buffer, i.name); + try writeOptionalString(allocator, buffer, i.notes); + + // Type + try writer.writeByte(@intFromEnum(i.item_type)); + + // Flags + try writer.writeByte(if (i.favorite) 1 else 0); + + // Timestamps + try writer.writeInt(i64, i.created_at, .little); + try writer.writeInt(i64, i.modified_at, .little); + try writeOptionalI64(allocator, buffer, i.last_accessed_at); + try writer.writeInt(u32, i.access_count, .little); + try writeOptionalI64(allocator, buffer, i.deleted_at); + + // Custom fields + try writer.writeInt(u32, @intCast(i.fields.len), .little); + for (i.fields) |f| { + try serializeCustomField(allocator, buffer, f); + } + + // Password history + try writer.writeInt(u32, @intCast(i.password_history.len), .little); + for (i.password_history) |h| { + try writeString(allocator, buffer, h.password); + try writer.writeInt(i64, h.changed_at, .little); + } + + // Type-specific data + try serializeItemData(allocator, buffer, i.data); +} + +fn serializeCustomField(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), f: item.CustomField) !void { + var writer = buffer.writer(allocator); + try writeString(allocator, buffer, f.name); + try writeOptionalString(allocator, buffer, f.value); + try writer.writeByte(@intFromEnum(f.field_type)); +} + +fn serializeItemData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), data: item.ItemData) !void { + switch (data) { + .login => |l| try serializeLoginData(allocator, buffer, l), + .secure_note => {}, + .card => |c| try serializeCardData(allocator, buffer, c), + .identity => |i| try serializeIdentityData(allocator, buffer, i), + .ssh_key => |s| try serializeSshKeyData(allocator, buffer, s), + .api_credential => |a| try serializeApiCredentialData(allocator, buffer, a), + .database => |d| try serializeDatabaseData(allocator, buffer, d), + .wifi => |w| try serializeWifiData(allocator, buffer, w), + .license => |l| try serializeLicenseData(allocator, buffer, l), + } +} + +fn serializeLoginData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), l: item.LoginData) !void { + var writer = buffer.writer(allocator); + + try writeOptionalString(allocator, buffer, l.username); + try writeOptionalString(allocator, buffer, l.password); + + // URIs + try writer.writeInt(u32, @intCast(l.uris.len), .little); + for (l.uris) |uri| { + try writeString(allocator, buffer, uri.uri); + try writer.writeByte(@intFromEnum(uri.match_type)); + } + + // TOTP + if (l.totp) |t| { + try writer.writeByte(1); + try writeString(allocator, buffer, t.secret); + try writer.writeByte(@intFromEnum(t.algorithm)); + try writer.writeByte(t.digits); + try writer.writeInt(u32, t.period, .little); + } else { + try writer.writeByte(0); + } + + // Passkeys + try writer.writeInt(u32, @intCast(l.passkeys.len), .little); + for (l.passkeys) |pk| { + try writeString(allocator, buffer, pk.credential_id); + try writeString(allocator, buffer, pk.private_key); + try writeString(allocator, buffer, pk.public_key); + try writer.writeInt(i32, @intFromEnum(pk.algorithm), .little); + try writeString(allocator, buffer, pk.rp_id); + try writeOptionalString(allocator, buffer, pk.rp_name); + try writeString(allocator, buffer, pk.user_handle); + try writeOptionalString(allocator, buffer, pk.user_name); + try writer.writeInt(u32, pk.counter, .little); + } +} + +fn serializeCardData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), c: item.CardData) !void { + var writer = buffer.writer(allocator); + + try writeOptionalString(allocator, buffer, c.cardholder_name); + try writeOptionalString(allocator, buffer, c.number); + try writer.writeByte(if (c.brand) |b| @intFromEnum(b) else 0); + try writer.writeByte(c.exp_month orelse 0); + try writer.writeInt(u16, c.exp_year orelse 0, .little); + try writeOptionalString(allocator, buffer, c.cvv); + try writeOptionalString(allocator, buffer, c.pin); +} + +fn serializeIdentityData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), i: item.IdentityData) !void { + var writer = buffer.writer(allocator); + + try writeOptionalString(allocator, buffer, i.title); + try writeOptionalString(allocator, buffer, i.first_name); + try writeOptionalString(allocator, buffer, i.middle_name); + try writeOptionalString(allocator, buffer, i.last_name); + try writeOptionalString(allocator, buffer, i.email); + try writeOptionalString(allocator, buffer, i.phone); + + // Address + if (i.address) |a| { + try writer.writeByte(1); + try writeOptionalString(allocator, buffer, a.street1); + try writeOptionalString(allocator, buffer, a.street2); + try writeOptionalString(allocator, buffer, a.city); + try writeOptionalString(allocator, buffer, a.state); + try writeOptionalString(allocator, buffer, a.postal_code); + try writeOptionalString(allocator, buffer, a.country); + } else { + try writer.writeByte(0); + } + + try writeOptionalString(allocator, buffer, i.ssn); + try writeOptionalString(allocator, buffer, i.passport); + try writeOptionalString(allocator, buffer, i.license_number); + try writeOptionalString(allocator, buffer, i.company); + try writeOptionalString(allocator, buffer, i.job_title); +} + +fn serializeSshKeyData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), s: item.SshKeyData) !void { + var writer = buffer.writer(allocator); + + try writeString(allocator, buffer, s.private_key); + try writeString(allocator, buffer, s.public_key); + try writeString(allocator, buffer, s.fingerprint); + try writer.writeByte(@intFromEnum(s.key_type)); + try writeOptionalString(allocator, buffer, s.passphrase); +} + +fn serializeApiCredentialData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), a: item.ApiCredentialData) !void { + try writeOptionalString(allocator, buffer, a.api_key); + try writeOptionalString(allocator, buffer, a.api_secret); + try writeOptionalString(allocator, buffer, a.endpoint); + try writeOptionalString(allocator, buffer, a.documentation_url); +} + +fn serializeDatabaseData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), d: item.DatabaseData) !void { + var writer = buffer.writer(allocator); + + try writer.writeByte(@intFromEnum(d.db_type)); + try writeOptionalString(allocator, buffer, d.host); + try writer.writeInt(u16, d.port orelse 0, .little); + try writeOptionalString(allocator, buffer, d.database); + try writeOptionalString(allocator, buffer, d.username); + try writeOptionalString(allocator, buffer, d.password); + try writeOptionalString(allocator, buffer, d.connection_string); + try writeOptionalString(allocator, buffer, d.sid); +} + +fn serializeWifiData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), w: item.WifiData) !void { + var writer = buffer.writer(allocator); + + try writeString(allocator, buffer, w.ssid); + try writeOptionalString(allocator, buffer, w.password); + try writer.writeByte(@intFromEnum(w.security)); + try writer.writeByte(if (w.hidden) 1 else 0); +} + +fn serializeLicenseData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), l: item.LicenseData) !void { + try writeOptionalString(allocator, buffer, l.license_key); + try writeOptionalString(allocator, buffer, l.product_name); + try writeOptionalString(allocator, buffer, l.version); + try writeOptionalString(allocator, buffer, l.publisher); + try writeOptionalString(allocator, buffer, l.email); + try writeOptionalI64(allocator, buffer, l.purchase_date); + try writeOptionalI64(allocator, buffer, l.expiration_date); +} + +fn writeString(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), s: []const u8) !void { + var writer = buffer.writer(allocator); + try writer.writeInt(u32, @intCast(s.len), .little); + try buffer.appendSlice(allocator, s); +} + +fn writeOptionalString(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), s: ?[]const u8) !void { + var writer = buffer.writer(allocator); + if (s) |str| { + try writer.writeByte(1); + try writeString(allocator, buffer, str); + } else { + try writer.writeByte(0); + } +} + +fn writeOptionalI64(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), v: ?i64) !void { + var writer = buffer.writer(allocator); + if (v) |val| { + try writer.writeByte(1); + try writer.writeInt(i64, val, .little); + } else { + try writer.writeByte(0); + } +} + +// Deserialization functions will be added in next task + +test "serialize empty items" { + const allocator = std.testing.allocator; + var items: [0]item.Item = .{}; + + const serialized = try serializeItems(allocator, &items); + defer allocator.free(serialized); + + try std.testing.expectEqual(@as(usize, 4), serialized.len); // Just the count +} +``` + +**Step 2: Run test** + +Run: `zig build test 2>&1 | grep -E "(serialize|PASS|FAIL)"` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/core/serializer_v2.zig +git commit -m "feat(core): add serializer v2 with serialize functions for all item types" +``` + +--- + +## Task 9: Add Deserializer V2 + +**Files:** +- Modify: `src/core/serializer_v2.zig` + +**Step 1: Add deserialization functions** + +Add to `serializer_v2.zig`: + +```zig +pub fn deserializeItems(allocator: std.mem.Allocator, data: []const u8) ![]item.Item { + var stream = std.io.fixedBufferStream(data); + var reader = stream.reader(); + + const count = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; + + var items: std.ArrayList(item.Item) = .empty; + errdefer { + for (items.items) |*i| i.deinit(allocator); + items.deinit(allocator); + } + + for (0..count) |_| { + const i = try deserializeItem(allocator, &reader); + try items.append(allocator, i); + } + + return items.toOwnedSlice(allocator); +} + +fn deserializeItem(allocator: std.mem.Allocator, reader: anytype) !item.Item { + // UUID + var uuid_bytes: [16]u8 = undefined; + _ = reader.readAll(&uuid_bytes) catch return DeserializeError.UnexpectedEndOfData; + const id = Uuid.fromBytes(uuid_bytes); + + // Name and notes + const name = try readString(allocator, reader); + errdefer allocator.free(name); + + const notes = try readOptionalString(allocator, reader); + errdefer if (notes) |n| allocator.free(n); + + // Type + const type_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const item_type = std.meta.intToEnum(item.ItemType, type_byte) catch return DeserializeError.InvalidItemType; + + // Flags + const favorite_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const favorite = favorite_byte == 1; + + // Timestamps + const created_at = reader.readInt(i64, .little) catch return DeserializeError.UnexpectedEndOfData; + const modified_at = reader.readInt(i64, .little) catch return DeserializeError.UnexpectedEndOfData; + const last_accessed_at = try readOptionalI64(reader); + const access_count = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; + const deleted_at = try readOptionalI64(reader); + + // Custom fields + const fields_count = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; + var fields = try allocator.alloc(item.CustomField, fields_count); + errdefer allocator.free(fields); + + for (0..fields_count) |i| { + fields[i] = try deserializeCustomField(allocator, reader); + } + + // Password history + const history_count = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; + var history = try allocator.alloc(item.PasswordHistoryEntry, history_count); + errdefer allocator.free(history); + + for (0..history_count) |i| { + const pw = try readString(allocator, reader); + const changed_at = reader.readInt(i64, .little) catch return DeserializeError.UnexpectedEndOfData; + history[i] = .{ .password = pw, .changed_at = changed_at }; + } + + // Type-specific data + const data = try deserializeItemData(allocator, reader, item_type); + + return item.Item{ + .id = id, + .name = name, + .notes = notes, + .item_type = item_type, + .data = data, + .favorite = favorite, + .fields = fields, + .password_history = history, + .created_at = created_at, + .modified_at = modified_at, + .last_accessed_at = last_accessed_at, + .access_count = access_count, + .deleted_at = deleted_at, + }; +} + +fn deserializeCustomField(allocator: std.mem.Allocator, reader: anytype) !item.CustomField { + const name = try readString(allocator, reader); + errdefer allocator.free(name); + + const value = try readOptionalString(allocator, reader); + errdefer if (value) |v| allocator.free(v); + + const type_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const field_type = std.meta.intToEnum(item.FieldType, type_byte) catch return DeserializeError.InvalidData; + + return .{ + .name = name, + .value = value, + .field_type = field_type, + }; +} + +fn deserializeItemData(allocator: std.mem.Allocator, reader: anytype, item_type: item.ItemType) !item.ItemData { + return switch (item_type) { + .login => .{ .login = try deserializeLoginData(allocator, reader) }, + .secure_note => .{ .secure_note = .{} }, + .card => .{ .card = try deserializeCardData(allocator, reader) }, + .identity => .{ .identity = try deserializeIdentityData(allocator, reader) }, + .ssh_key => .{ .ssh_key = try deserializeSshKeyData(allocator, reader) }, + .api_credential => .{ .api_credential = try deserializeApiCredentialData(allocator, reader) }, + .database => .{ .database = try deserializeDatabaseData(allocator, reader) }, + .wifi => .{ .wifi = try deserializeWifiData(allocator, reader) }, + .license => .{ .license = try deserializeLicenseData(allocator, reader) }, + }; +} + +fn deserializeLoginData(allocator: std.mem.Allocator, reader: anytype) !item.LoginData { + const username = try readOptionalString(allocator, reader); + errdefer if (username) |u| allocator.free(u); + + const password = try readOptionalString(allocator, reader); + errdefer if (password) |p| allocator.free(p); + + // URIs + const uri_count = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; + var uris = try allocator.alloc(item.Uri, uri_count); + errdefer allocator.free(uris); + + for (0..uri_count) |i| { + const uri_str = try readString(allocator, reader); + const match_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const match_type = std.meta.intToEnum(item.UriMatchType, match_byte) catch return DeserializeError.InvalidData; + uris[i] = .{ .uri = uri_str, .match_type = match_type }; + } + + // TOTP + const has_totp = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + var totp: ?item.TotpData = null; + if (has_totp == 1) { + const secret = try readString(allocator, reader); + const algo_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const algorithm = std.meta.intToEnum(item.TotpAlgorithm, algo_byte) catch return DeserializeError.InvalidData; + const digits = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const period = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; + totp = .{ + .secret = secret, + .algorithm = algorithm, + .digits = digits, + .period = period, + }; + } + + // Passkeys + const pk_count = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; + var passkeys = try allocator.alloc(item.PasskeyData, pk_count); + errdefer allocator.free(passkeys); + + for (0..pk_count) |i| { + passkeys[i] = try deserializePasskeyData(allocator, reader); + } + + return .{ + .username = username, + .password = password, + .uris = uris, + .totp = totp, + .passkeys = passkeys, + }; +} + +fn deserializePasskeyData(allocator: std.mem.Allocator, reader: anytype) !item.PasskeyData { + const credential_id = try readString(allocator, reader); + const private_key = try readString(allocator, reader); + const public_key = try readString(allocator, reader); + const algo_int = reader.readInt(i32, .little) catch return DeserializeError.UnexpectedEndOfData; + const algorithm = std.meta.intToEnum(item.PasskeyAlgorithm, algo_int) catch return DeserializeError.InvalidData; + const rp_id = try readString(allocator, reader); + const rp_name = try readOptionalString(allocator, reader); + const user_handle = try readString(allocator, reader); + const user_name = try readOptionalString(allocator, reader); + const counter = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; + + return .{ + .credential_id = credential_id, + .private_key = private_key, + .public_key = public_key, + .algorithm = algorithm, + .rp_id = rp_id, + .rp_name = rp_name, + .user_handle = user_handle, + .user_name = user_name, + .counter = counter, + }; +} + +fn deserializeCardData(allocator: std.mem.Allocator, reader: anytype) !item.CardData { + const cardholder = try readOptionalString(allocator, reader); + const number = try readOptionalString(allocator, reader); + const brand_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const brand: ?item.CardBrand = if (brand_byte == 0) null else std.meta.intToEnum(item.CardBrand, brand_byte) catch null; + const exp_month_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const exp_month: ?u8 = if (exp_month_byte == 0) null else exp_month_byte; + const exp_year_val = reader.readInt(u16, .little) catch return DeserializeError.UnexpectedEndOfData; + const exp_year: ?u16 = if (exp_year_val == 0) null else exp_year_val; + const cvv = try readOptionalString(allocator, reader); + const pin = try readOptionalString(allocator, reader); + + return .{ + .cardholder_name = cardholder, + .number = number, + .brand = brand, + .exp_month = exp_month, + .exp_year = exp_year, + .cvv = cvv, + .pin = pin, + }; +} + +fn deserializeIdentityData(allocator: std.mem.Allocator, reader: anytype) !item.IdentityData { + const title = try readOptionalString(allocator, reader); + const first_name = try readOptionalString(allocator, reader); + const middle_name = try readOptionalString(allocator, reader); + const last_name = try readOptionalString(allocator, reader); + const email = try readOptionalString(allocator, reader); + const phone = try readOptionalString(allocator, reader); + + const has_address = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + var address: ?item.AddressData = null; + if (has_address == 1) { + address = .{ + .street1 = try readOptionalString(allocator, reader), + .street2 = try readOptionalString(allocator, reader), + .city = try readOptionalString(allocator, reader), + .state = try readOptionalString(allocator, reader), + .postal_code = try readOptionalString(allocator, reader), + .country = try readOptionalString(allocator, reader), + }; + } + + const ssn = try readOptionalString(allocator, reader); + const passport = try readOptionalString(allocator, reader); + const license_number = try readOptionalString(allocator, reader); + const company = try readOptionalString(allocator, reader); + const job_title = try readOptionalString(allocator, reader); + + return .{ + .title = title, + .first_name = first_name, + .middle_name = middle_name, + .last_name = last_name, + .email = email, + .phone = phone, + .address = address, + .ssn = ssn, + .passport = passport, + .license_number = license_number, + .company = company, + .job_title = job_title, + }; +} + +fn deserializeSshKeyData(allocator: std.mem.Allocator, reader: anytype) !item.SshKeyData { + const private_key = try readString(allocator, reader); + const public_key = try readString(allocator, reader); + const fingerprint = try readString(allocator, reader); + const type_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const key_type = std.meta.intToEnum(item.SshKeyType, type_byte) catch return DeserializeError.InvalidData; + const passphrase = try readOptionalString(allocator, reader); + + return .{ + .private_key = private_key, + .public_key = public_key, + .fingerprint = fingerprint, + .key_type = key_type, + .passphrase = passphrase, + }; +} + +fn deserializeApiCredentialData(allocator: std.mem.Allocator, reader: anytype) !item.ApiCredentialData { + return .{ + .api_key = try readOptionalString(allocator, reader), + .api_secret = try readOptionalString(allocator, reader), + .endpoint = try readOptionalString(allocator, reader), + .documentation_url = try readOptionalString(allocator, reader), + }; +} + +fn deserializeDatabaseData(allocator: std.mem.Allocator, reader: anytype) !item.DatabaseData { + const type_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const db_type = std.meta.intToEnum(item.DatabaseType, type_byte) catch return DeserializeError.InvalidData; + const host = try readOptionalString(allocator, reader); + const port_val = reader.readInt(u16, .little) catch return DeserializeError.UnexpectedEndOfData; + const port: ?u16 = if (port_val == 0) null else port_val; + const database = try readOptionalString(allocator, reader); + const username = try readOptionalString(allocator, reader); + const password = try readOptionalString(allocator, reader); + const connection_string = try readOptionalString(allocator, reader); + const sid = try readOptionalString(allocator, reader); + + return .{ + .db_type = db_type, + .host = host, + .port = port, + .database = database, + .username = username, + .password = password, + .connection_string = connection_string, + .sid = sid, + }; +} + +fn deserializeWifiData(allocator: std.mem.Allocator, reader: anytype) !item.WifiData { + const ssid = try readString(allocator, reader); + const password = try readOptionalString(allocator, reader); + const sec_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const security = std.meta.intToEnum(item.WifiSecurity, sec_byte) catch return DeserializeError.InvalidData; + const hidden_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + + return .{ + .ssid = ssid, + .password = password, + .security = security, + .hidden = hidden_byte == 1, + }; +} + +fn deserializeLicenseData(allocator: std.mem.Allocator, reader: anytype) !item.LicenseData { + return .{ + .license_key = try readOptionalString(allocator, reader), + .product_name = try readOptionalString(allocator, reader), + .version = try readOptionalString(allocator, reader), + .publisher = try readOptionalString(allocator, reader), + .email = try readOptionalString(allocator, reader), + .purchase_date = try readOptionalI64(reader), + .expiration_date = try readOptionalI64(reader), + }; +} + +fn readString(allocator: std.mem.Allocator, reader: anytype) ![]u8 { + const len = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; + if (len > 1024 * 1024) return DeserializeError.InvalidData; + + const str = try allocator.alloc(u8, len); + errdefer allocator.free(str); + + const bytes_read = reader.readAll(str) catch return DeserializeError.UnexpectedEndOfData; + if (bytes_read != len) return DeserializeError.UnexpectedEndOfData; + + return str; +} + +fn readOptionalString(allocator: std.mem.Allocator, reader: anytype) !?[]u8 { + const present = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + if (present == 1) { + return try readString(allocator, reader); + } + return null; +} + +fn readOptionalI64(reader: anytype) !?i64 { + const present = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + if (present == 1) { + return reader.readInt(i64, .little) catch return DeserializeError.UnexpectedEndOfData; + } + return null; +} + +test "serialize and deserialize login item roundtrip" { + const allocator = std.testing.allocator; + + var login_item = try item.createLoginItem(allocator, "github.com", "testuser", "secretpass", "https://github.com"); + defer login_item.deinit(allocator); + + var items_arr = [_]item.Item{login_item}; + // Don't defer deinit on items_arr since login_item handles it + + const serialized = try serializeItems(allocator, &items_arr); + defer allocator.free(serialized); + + // Need to make a copy since we'll deserialize into new memory + var items_for_serialize = [_]item.Item{login_item}; + _ = &items_for_serialize; + + // Actually test deserialization + const deserialized = try deserializeItems(allocator, serialized); + defer { + for (deserialized) |*i| i.deinit(allocator); + allocator.free(deserialized); + } + + try std.testing.expectEqual(@as(usize, 1), deserialized.len); + try std.testing.expectEqualStrings("github.com", deserialized[0].name); + try std.testing.expectEqual(item.ItemType.login, deserialized[0].item_type); + try std.testing.expectEqualStrings("testuser", deserialized[0].data.login.username.?); +} +``` + +**Step 2: Run test** + +Run: `zig build test 2>&1 | grep -E "(roundtrip|PASS|FAIL)"` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/core/serializer_v2.zig +git commit -m "feat(core): add deserializer v2 with roundtrip test" +``` + +--- + +## Task 10: Update Vault Header for V2 + +**Files:** +- Modify: `src/core/vault.zig` + +**Step 1: Update VAULT_VERSION constant** + +Change line 23 in vault.zig: +```zig +pub const VAULT_VERSION: u16 = 2; +``` + +**Step 2: Add version check that allows both 1 and 2** + +In `VaultHeader.deserialize`, update the version check: +```zig +if (version != 1 and version != 2) { + return VaultError.UnsupportedVersion; +} +``` + +**Step 3: Run existing tests** + +Run: `zig build test` +Expected: All existing tests pass + +**Step 4: Commit** + +```bash +git add src/core/vault.zig +git commit -m "feat(core): bump vault version to 2, allow reading v1 and v2" +``` + +--- + +## Task 11: Add Migration from V1 to V2 + +**Files:** +- Create: `src/core/migration.zig` + +**Step 1: Create migration module** + +```zig +const std = @import("std"); +const item = @import("item.zig"); +const entry = @import("entry.zig"); +const Uuid = @import("uuid.zig").Uuid; + +pub fn migrateEntryToItem(allocator: std.mem.Allocator, e: entry.Entry) !item.Item { + const now = std.time.timestamp(); + + const name_copy = try allocator.dupe(u8, e.name); + errdefer allocator.free(name_copy); + + switch (e.data) { + .password => |p| { + return try migratePasswordEntry(allocator, name_copy, e.created_at, e.modified_at, p); + }, + .totp => |t| { + return try migrateTotpEntry(allocator, name_copy, e.created_at, e.modified_at, t); + }, + .passkey => |pk| { + return try migratePasskeyEntry(allocator, name_copy, e.created_at, e.modified_at, pk); + }, + } + _ = now; +} + +fn migratePasswordEntry( + allocator: std.mem.Allocator, + name: []const u8, + created_at: i64, + modified_at: i64, + p: entry.PasswordEntry, +) !item.Item { + const username_copy = if (p.username) |u| try allocator.dupe(u8, u) else null; + errdefer if (username_copy) |u| allocator.free(u); + + const password_copy = try allocator.dupe(u8, p.password); + errdefer allocator.free(password_copy); + + const notes_copy = if (p.notes) |n| try allocator.dupe(u8, n) else null; + errdefer if (notes_copy) |n| allocator.free(n); + + // URIs + var uris: []item.Uri = &.{}; + if (p.url) |url| { + const uri_copy = try allocator.dupe(u8, url); + errdefer allocator.free(uri_copy); + + uris = try allocator.alloc(item.Uri, 1); + uris[0] = .{ .uri = uri_copy }; + } + + // TOTP + var totp: ?item.TotpData = null; + if (p.totp_secret) |secret| { + const secret_copy = try allocator.dupe(u8, secret); + totp = .{ + .secret = secret_copy, + .algorithm = switch (p.totp_algorithm) { + .sha1 => .sha1, + .sha256 => .sha256, + .sha512 => .sha512, + }, + .digits = p.totp_digits, + .period = p.totp_period, + }; + } + + return item.Item{ + .id = Uuid.generate(), + .name = name, + .notes = notes_copy, + .item_type = .login, + .data = .{ + .login = .{ + .username = username_copy, + .password = password_copy, + .uris = uris, + .totp = totp, + .passkeys = &.{}, + }, + }, + .favorite = false, + .fields = &.{}, + .password_history = &.{}, + .created_at = created_at, + .modified_at = modified_at, + .last_accessed_at = null, + .access_count = 0, + .deleted_at = null, + }; +} + +fn migrateTotpEntry( + allocator: std.mem.Allocator, + name: []const u8, + created_at: i64, + modified_at: i64, + t: entry.TotpEntry, +) !item.Item { + const secret_copy = try allocator.dupe(u8, t.secret); + errdefer allocator.free(secret_copy); + + const issuer_notes = if (t.issuer) |i| try allocator.dupe(u8, i) else null; + + return item.Item{ + .id = Uuid.generate(), + .name = name, + .notes = issuer_notes, + .item_type = .login, + .data = .{ + .login = .{ + .username = null, + .password = null, + .uris = &.{}, + .totp = .{ + .secret = secret_copy, + .algorithm = switch (t.algorithm) { + .sha1 => .sha1, + .sha256 => .sha256, + .sha512 => .sha512, + }, + .digits = t.digits, + .period = t.period, + }, + .passkeys = &.{}, + }, + }, + .favorite = false, + .fields = &.{}, + .password_history = &.{}, + .created_at = created_at, + .modified_at = modified_at, + .last_accessed_at = null, + .access_count = 0, + .deleted_at = null, + }; +} + +fn migratePasskeyEntry( + allocator: std.mem.Allocator, + name: []const u8, + created_at: i64, + modified_at: i64, + pk: entry.PasskeyEntry, +) !item.Item { + const cred_copy = try allocator.dupe(u8, pk.credential_id); + errdefer allocator.free(cred_copy); + + const priv_copy = try allocator.dupe(u8, pk.private_key); + errdefer allocator.free(priv_copy); + + const pub_copy = try allocator.dupe(u8, pk.public_key); + errdefer allocator.free(pub_copy); + + const rp_id_copy = try allocator.dupe(u8, pk.rp_id); + errdefer allocator.free(rp_id_copy); + + const rp_name_copy = if (pk.rp_name) |n| try allocator.dupe(u8, n) else null; + errdefer if (rp_name_copy) |n| allocator.free(n); + + const user_handle_copy = try allocator.dupe(u8, pk.user_handle); + errdefer allocator.free(user_handle_copy); + + const user_name_copy = if (pk.user_name) |n| try allocator.dupe(u8, n) else null; + errdefer if (user_name_copy) |n| allocator.free(n); + + var passkeys = try allocator.alloc(item.PasskeyData, 1); + passkeys[0] = .{ + .credential_id = cred_copy, + .private_key = priv_copy, + .public_key = pub_copy, + .algorithm = switch (pk.algorithm) { + .es256 => .es256, + .ed25519 => .ed25519, + .rs256 => .rs256, + }, + .rp_id = rp_id_copy, + .rp_name = rp_name_copy, + .user_handle = user_handle_copy, + .user_name = user_name_copy, + .counter = pk.counter, + }; + + // Use rp_id as URI + const uri_copy = try allocator.dupe(u8, pk.rp_id); + var uris = try allocator.alloc(item.Uri, 1); + uris[0] = .{ .uri = uri_copy }; + + return item.Item{ + .id = Uuid.generate(), + .name = name, + .notes = null, + .item_type = .login, + .data = .{ + .login = .{ + .username = user_name_copy, + .password = null, + .uris = uris, + .totp = null, + .passkeys = passkeys, + }, + }, + .favorite = false, + .fields = &.{}, + .password_history = &.{}, + .created_at = created_at, + .modified_at = modified_at, + .last_accessed_at = null, + .access_count = 0, + .deleted_at = null, + }; +} + +pub fn migrateAllEntries(allocator: std.mem.Allocator, entries: []const entry.Entry) ![]item.Item { + var items: std.ArrayList(item.Item) = .empty; + errdefer { + for (items.items) |*i| i.deinit(allocator); + items.deinit(allocator); + } + + for (entries) |e| { + const migrated = try migrateEntryToItem(allocator, e); + try items.append(allocator, migrated); + } + + return items.toOwnedSlice(allocator); +} + +test "migrate password entry to login item" { + const allocator = std.testing.allocator; + + const e = try entry.createPasswordEntry(allocator, "test.com", "user", "pass", "https://test.com"); + var mutable_entry = e; + defer mutable_entry.deinit(allocator); + + var migrated = try migrateEntryToItem(allocator, e); + defer migrated.deinit(allocator); + + try std.testing.expectEqual(item.ItemType.login, migrated.item_type); + try std.testing.expectEqualStrings("test.com", migrated.name); + try std.testing.expectEqualStrings("user", migrated.data.login.username.?); + try std.testing.expectEqualStrings("pass", migrated.data.login.password.?); + try std.testing.expectEqual(@as(usize, 1), migrated.data.login.uris.len); +} +``` + +**Step 2: Run test** + +Run: `zig build test 2>&1 | grep -E "(migrate|PASS|FAIL)"` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/core/migration.zig +git commit -m "feat(core): add migration module for v1 entries to v2 items" +``` + +--- + +## Summary + +After completing all 11 tasks, Phase 1 delivers: + +1. **UUID type** for unique item identification +2. **Item struct** with all 9 item types +3. **Full data structures** for Login, Card, Identity, SSH Key, API Credential, Database, WiFi, License +4. **Custom fields** with typed values +5. **Password history** entries +6. **Serializer V2** for the new format +7. **Migration** from V1 entries to V2 items +8. **Vault header** updated to version 2 + +Next phase will add folders, tags, and quick access features. From 0db46133df5681a6c5385ed72d616858d62db487 Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:08:02 +0100 Subject: [PATCH 03/19] feat(core): add UUID type for item identifiers --- src/core/uuid.zig | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/core/uuid.zig diff --git a/src/core/uuid.zig b/src/core/uuid.zig new file mode 100644 index 0000000..ba7184f --- /dev/null +++ b/src/core/uuid.zig @@ -0,0 +1,50 @@ +const std = @import("std"); + +pub const Uuid = struct { + bytes: [16]u8, + + pub fn generate() Uuid { + var bytes: [16]u8 = undefined; + std.crypto.random.bytes(&bytes); + // Set version 4 (random) and variant bits + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + return .{ .bytes = bytes }; + } + + pub fn fromBytes(bytes: [16]u8) Uuid { + return .{ .bytes = bytes }; + } + + pub fn eql(self: Uuid, other: Uuid) bool { + return std.mem.eql(u8, &self.bytes, &other.bytes); + } + + pub fn isZero(self: Uuid) bool { + return std.mem.eql(u8, &self.bytes, &[_]u8{0} ** 16); + } + + pub const zero = Uuid{ .bytes = [_]u8{0} ** 16 }; +}; + +test "uuid generate produces unique values" { + const a = Uuid.generate(); + const b = Uuid.generate(); + try std.testing.expect(!a.eql(b)); +} + +test "uuid zero check" { + const z = Uuid.zero; + try std.testing.expect(z.isZero()); + + const g = Uuid.generate(); + try std.testing.expect(!g.isZero()); +} + +test "uuid version 4 format" { + const u = Uuid.generate(); + // Version should be 4 + try std.testing.expectEqual(@as(u8, 4), (u.bytes[6] >> 4) & 0x0f); + // Variant should be 10xx + try std.testing.expectEqual(@as(u8, 2), (u.bytes[8] >> 6) & 0x03); +} From 2661afb104ab654a19bf3aeef83c219adae74eaa Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:08:38 +0100 Subject: [PATCH 04/19] feat(core): add Item type with enums and base structures --- src/core/item.zig | 164 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 src/core/item.zig diff --git a/src/core/item.zig b/src/core/item.zig new file mode 100644 index 0000000..4cca491 --- /dev/null +++ b/src/core/item.zig @@ -0,0 +1,164 @@ +const std = @import("std"); +const Uuid = @import("uuid.zig").Uuid; + +pub const ItemType = enum(u8) { + login = 1, + secure_note = 2, + card = 3, + identity = 4, + ssh_key = 5, + api_credential = 6, + database = 7, + wifi = 8, + license = 9, +}; + +pub const FieldType = enum(u8) { + text = 0, + hidden = 1, + boolean = 2, + url = 3, + email = 4, + date = 5, + month_year = 6, + phone = 7, + totp = 8, + linked = 9, +}; + +pub const CustomField = struct { + name: []const u8, + value: ?[]const u8, + field_type: FieldType, + + pub fn deinit(self: *CustomField, allocator: std.mem.Allocator) void { + allocator.free(self.name); + if (self.value) |v| allocator.free(v); + } +}; + +pub const PasswordHistoryEntry = struct { + password: []const u8, + changed_at: i64, + + pub fn deinit(self: *PasswordHistoryEntry, allocator: std.mem.Allocator) void { + @memset(@constCast(self.password), 0); + allocator.free(self.password); + } +}; + +pub const Item = struct { + id: Uuid, + name: []const u8, + notes: ?[]const u8, + item_type: ItemType, + data: ItemData, + favorite: bool, + fields: []CustomField, + password_history: []PasswordHistoryEntry, + created_at: i64, + modified_at: i64, + last_accessed_at: ?i64, + access_count: u32, + deleted_at: ?i64, + + const Self = @This(); + + pub fn deinit(self: *Self, allocator: std.mem.Allocator) void { + allocator.free(self.name); + if (self.notes) |n| allocator.free(n); + + for (self.fields) |*f| f.deinit(allocator); + if (self.fields.len > 0) allocator.free(self.fields); + + for (self.password_history) |*h| h.deinit(allocator); + if (self.password_history.len > 0) allocator.free(self.password_history); + + self.data.deinit(allocator); + } + + pub fn updateLastAccessed(self: *Self) void { + self.last_accessed_at = std.time.timestamp(); + self.access_count += 1; + } +}; + +pub const ItemData = union(ItemType) { + login: LoginData, + secure_note: SecureNoteData, + card: CardData, + identity: IdentityData, + ssh_key: SshKeyData, + api_credential: ApiCredentialData, + database: DatabaseData, + wifi: WifiData, + license: LicenseData, + + pub fn deinit(self: *ItemData, allocator: std.mem.Allocator) void { + switch (self.*) { + .login => |*d| d.deinit(allocator), + .secure_note => {}, + .card => |*d| d.deinit(allocator), + .identity => |*d| d.deinit(allocator), + .ssh_key => |*d| d.deinit(allocator), + .api_credential => |*d| d.deinit(allocator), + .database => |*d| d.deinit(allocator), + .wifi => |*d| d.deinit(allocator), + .license => |*d| d.deinit(allocator), + } + } +}; + +// Placeholder structs - will be filled in next tasks +pub const LoginData = struct { + username: ?[]const u8 = null, + password: ?[]const u8 = null, + + pub fn deinit(self: *LoginData, allocator: std.mem.Allocator) void { + if (self.username) |u| allocator.free(u); + if (self.password) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + } + } +}; + +pub const SecureNoteData = struct {}; + +pub const CardData = struct { + pub fn deinit(_: *CardData, _: std.mem.Allocator) void {} +}; + +pub const IdentityData = struct { + pub fn deinit(_: *IdentityData, _: std.mem.Allocator) void {} +}; + +pub const SshKeyData = struct { + pub fn deinit(_: *SshKeyData, _: std.mem.Allocator) void {} +}; + +pub const ApiCredentialData = struct { + pub fn deinit(_: *ApiCredentialData, _: std.mem.Allocator) void {} +}; + +pub const DatabaseData = struct { + pub fn deinit(_: *DatabaseData, _: std.mem.Allocator) void {} +}; + +pub const WifiData = struct { + pub fn deinit(_: *WifiData, _: std.mem.Allocator) void {} +}; + +pub const LicenseData = struct { + pub fn deinit(_: *LicenseData, _: std.mem.Allocator) void {} +}; + +test "item type enum values" { + try std.testing.expectEqual(@as(u8, 1), @intFromEnum(ItemType.login)); + try std.testing.expectEqual(@as(u8, 9), @intFromEnum(ItemType.license)); +} + +test "field type enum values" { + try std.testing.expectEqual(@as(u8, 0), @intFromEnum(FieldType.text)); + try std.testing.expectEqual(@as(u8, 1), @intFromEnum(FieldType.hidden)); +} From 36f7090ec546c3784c08d44a2abf7c04398b8543 Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:09:20 +0100 Subject: [PATCH 05/19] feat(core): implement full LoginData with URIs, TOTP, and passkeys --- src/core/item.zig | 95 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/src/core/item.zig b/src/core/item.zig index 4cca491..423dfdb 100644 --- a/src/core/item.zig +++ b/src/core/item.zig @@ -109,10 +109,77 @@ pub const ItemData = union(ItemType) { } }; -// Placeholder structs - will be filled in next tasks +pub const TotpAlgorithm = enum(u8) { + sha1 = 1, + sha256 = 2, + sha512 = 3, +}; + +pub const TotpData = struct { + secret: []const u8, + algorithm: TotpAlgorithm = .sha1, + digits: u8 = 6, + period: u32 = 30, + + pub fn deinit(self: *TotpData, allocator: std.mem.Allocator) void { + @memset(@constCast(self.secret), 0); + allocator.free(self.secret); + } +}; + +pub const UriMatchType = enum(u8) { + domain = 0, + host = 1, + starts_with = 2, + exact = 3, + regex = 4, + never = 5, +}; + +pub const Uri = struct { + uri: []const u8, + match_type: UriMatchType = .domain, + + pub fn deinit(self: *Uri, allocator: std.mem.Allocator) void { + allocator.free(self.uri); + } +}; + +pub const PasskeyAlgorithm = enum(i32) { + es256 = -7, + ed25519 = -8, + rs256 = -257, +}; + +pub const PasskeyData = struct { + credential_id: []const u8, + private_key: []const u8, + public_key: []const u8, + algorithm: PasskeyAlgorithm, + rp_id: []const u8, + rp_name: ?[]const u8, + user_handle: []const u8, + user_name: ?[]const u8, + counter: u32, + + pub fn deinit(self: *PasskeyData, allocator: std.mem.Allocator) void { + allocator.free(self.credential_id); + @memset(@constCast(self.private_key), 0); + allocator.free(self.private_key); + allocator.free(self.public_key); + allocator.free(self.rp_id); + if (self.rp_name) |n| allocator.free(n); + allocator.free(self.user_handle); + if (self.user_name) |n| allocator.free(n); + } +}; + pub const LoginData = struct { username: ?[]const u8 = null, password: ?[]const u8 = null, + uris: []Uri = &.{}, + totp: ?TotpData = null, + passkeys: []PasskeyData = &.{}, pub fn deinit(self: *LoginData, allocator: std.mem.Allocator) void { if (self.username) |u| allocator.free(u); @@ -120,6 +187,18 @@ pub const LoginData = struct { @memset(@constCast(p), 0); allocator.free(p); } + for (self.uris) |*uri| uri.deinit(allocator); + if (self.uris.len > 0) allocator.free(self.uris); + if (self.totp) |*t| { + var totp = t.*; + totp.deinit(allocator); + } + for (self.passkeys) |*pk| pk.deinit(allocator); + if (self.passkeys.len > 0) allocator.free(self.passkeys); + } + + pub fn hasTotp(self: *const LoginData) bool { + return self.totp != null; } }; @@ -162,3 +241,17 @@ test "field type enum values" { try std.testing.expectEqual(@as(u8, 0), @intFromEnum(FieldType.text)); try std.testing.expectEqual(@as(u8, 1), @intFromEnum(FieldType.hidden)); } + +test "login data deinit clears password" { + const allocator = std.testing.allocator; + + var login = LoginData{ + .username = try allocator.dupe(u8, "user"), + .password = try allocator.dupe(u8, "secret"), + .uris = &.{}, + .totp = null, + .passkeys = &.{}, + }; + + login.deinit(allocator); +} From cdb46c672dd6ae6b1ce72e24820fe32141fb1a17 Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:09:59 +0100 Subject: [PATCH 06/19] feat(core): implement CardData with brand and secure field handling --- src/core/item.zig | 55 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/src/core/item.zig b/src/core/item.zig index 423dfdb..a883b4c 100644 --- a/src/core/item.zig +++ b/src/core/item.zig @@ -204,8 +204,48 @@ pub const LoginData = struct { pub const SecureNoteData = struct {}; +pub const CardBrand = enum(u8) { + visa = 1, + mastercard = 2, + amex = 3, + discover = 4, + diners = 5, + jcb = 6, + unionpay = 7, + other = 255, +}; + pub const CardData = struct { - pub fn deinit(_: *CardData, _: std.mem.Allocator) void {} + cardholder_name: ?[]const u8 = null, + number: ?[]const u8 = null, + brand: ?CardBrand = null, + exp_month: ?u8 = null, + exp_year: ?u16 = null, + cvv: ?[]const u8 = null, + pin: ?[]const u8 = null, + + pub fn deinit(self: *CardData, allocator: std.mem.Allocator) void { + if (self.cardholder_name) |n| allocator.free(n); + if (self.number) |n| { + @memset(@constCast(n), 0); + allocator.free(n); + } + if (self.cvv) |c| { + @memset(@constCast(c), 0); + allocator.free(c); + } + if (self.pin) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + } + } + + pub fn lastFour(self: *const CardData) ?[]const u8 { + if (self.number) |n| { + if (n.len >= 4) return n[n.len - 4 ..]; + } + return null; + } }; pub const IdentityData = struct { @@ -255,3 +295,16 @@ test "login data deinit clears password" { login.deinit(allocator); } + +test "card data deinit clears sensitive fields" { + const allocator = std.testing.allocator; + + var card = CardData{ + .cardholder_name = try allocator.dupe(u8, "John Doe"), + .number = try allocator.dupe(u8, "4111111111111111"), + .cvv = try allocator.dupe(u8, "123"), + .pin = try allocator.dupe(u8, "1234"), + }; + + card.deinit(allocator); +} From 8c25fa278097b52e427f3bb658ea9218a20c2c6d Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:10:58 +0100 Subject: [PATCH 07/19] feat(core): implement IdentityData with address and sensitive field handling --- src/core/item.zig | 78 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/src/core/item.zig b/src/core/item.zig index a883b4c..945beab 100644 --- a/src/core/item.zig +++ b/src/core/item.zig @@ -248,8 +248,72 @@ pub const CardData = struct { } }; +pub const AddressData = struct { + street1: ?[]const u8 = null, + street2: ?[]const u8 = null, + city: ?[]const u8 = null, + state: ?[]const u8 = null, + postal_code: ?[]const u8 = null, + country: ?[]const u8 = null, + + pub fn deinit(self: *AddressData, allocator: std.mem.Allocator) void { + if (self.street1) |s| allocator.free(s); + if (self.street2) |s| allocator.free(s); + if (self.city) |c| allocator.free(c); + if (self.state) |s| allocator.free(s); + if (self.postal_code) |p| allocator.free(p); + if (self.country) |c| allocator.free(c); + } +}; + pub const IdentityData = struct { - pub fn deinit(_: *IdentityData, _: std.mem.Allocator) void {} + title: ?[]const u8 = null, + first_name: ?[]const u8 = null, + middle_name: ?[]const u8 = null, + last_name: ?[]const u8 = null, + email: ?[]const u8 = null, + phone: ?[]const u8 = null, + address: ?AddressData = null, + ssn: ?[]const u8 = null, + passport: ?[]const u8 = null, + license_number: ?[]const u8 = null, + company: ?[]const u8 = null, + job_title: ?[]const u8 = null, + + pub fn deinit(self: *IdentityData, allocator: std.mem.Allocator) void { + if (self.title) |t| allocator.free(t); + if (self.first_name) |n| allocator.free(n); + if (self.middle_name) |n| allocator.free(n); + if (self.last_name) |n| allocator.free(n); + if (self.email) |e| allocator.free(e); + if (self.phone) |p| allocator.free(p); + if (self.address) |*a| { + var addr = a.*; + addr.deinit(allocator); + } + if (self.ssn) |s| { + @memset(@constCast(s), 0); + allocator.free(s); + } + if (self.passport) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + } + if (self.license_number) |l| allocator.free(l); + if (self.company) |c| allocator.free(c); + if (self.job_title) |j| allocator.free(j); + } + + pub fn fullName(self: *const IdentityData, allocator: std.mem.Allocator) ![]const u8 { + var parts: std.ArrayList([]const u8) = .empty; + defer parts.deinit(allocator); + + if (self.first_name) |n| try parts.append(allocator, n); + if (self.middle_name) |n| try parts.append(allocator, n); + if (self.last_name) |n| try parts.append(allocator, n); + + return std.mem.join(allocator, " ", parts.items); + } }; pub const SshKeyData = struct { @@ -308,3 +372,15 @@ test "card data deinit clears sensitive fields" { card.deinit(allocator); } + +test "identity data full name" { + const identity = IdentityData{ + .first_name = "John", + .middle_name = "Q", + .last_name = "Public", + }; + + const full = try identity.fullName(std.testing.allocator); + defer std.testing.allocator.free(full); + try std.testing.expectEqualStrings("John Q Public", full); +} From cb15b5ec1576d2ea802690d907f853704fd46a2f Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:11:52 +0100 Subject: [PATCH 08/19] feat(core): implement SshKeyData, ApiCredentialData, DatabaseData, WifiData, LicenseData --- src/core/item.zig | 144 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 139 insertions(+), 5 deletions(-) diff --git a/src/core/item.zig b/src/core/item.zig index 945beab..c947fa8 100644 --- a/src/core/item.zig +++ b/src/core/item.zig @@ -316,24 +316,132 @@ pub const IdentityData = struct { } }; +pub const SshKeyType = enum(u8) { + ed25519 = 1, + rsa = 2, + ecdsa = 3, + dsa = 4, +}; + pub const SshKeyData = struct { - pub fn deinit(_: *SshKeyData, _: std.mem.Allocator) void {} + private_key: []const u8, + public_key: []const u8, + fingerprint: []const u8, + key_type: SshKeyType, + passphrase: ?[]const u8 = null, + + pub fn deinit(self: *SshKeyData, allocator: std.mem.Allocator) void { + @memset(@constCast(self.private_key), 0); + allocator.free(self.private_key); + allocator.free(self.public_key); + allocator.free(self.fingerprint); + if (self.passphrase) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + } + } }; pub const ApiCredentialData = struct { - pub fn deinit(_: *ApiCredentialData, _: std.mem.Allocator) void {} + api_key: ?[]const u8 = null, + api_secret: ?[]const u8 = null, + endpoint: ?[]const u8 = null, + documentation_url: ?[]const u8 = null, + + pub fn deinit(self: *ApiCredentialData, allocator: std.mem.Allocator) void { + if (self.api_key) |k| { + @memset(@constCast(k), 0); + allocator.free(k); + } + if (self.api_secret) |s| { + @memset(@constCast(s), 0); + allocator.free(s); + } + if (self.endpoint) |e| allocator.free(e); + if (self.documentation_url) |d| allocator.free(d); + } +}; + +pub const DatabaseType = enum(u8) { + postgresql = 1, + mysql = 2, + mariadb = 3, + sqlite = 4, + mongodb = 5, + redis = 6, + oracle = 7, + sqlserver = 8, + other = 255, }; pub const DatabaseData = struct { - pub fn deinit(_: *DatabaseData, _: std.mem.Allocator) void {} + db_type: DatabaseType = .postgresql, + host: ?[]const u8 = null, + port: ?u16 = null, + database: ?[]const u8 = null, + username: ?[]const u8 = null, + password: ?[]const u8 = null, + connection_string: ?[]const u8 = null, + sid: ?[]const u8 = null, + + pub fn deinit(self: *DatabaseData, allocator: std.mem.Allocator) void { + if (self.host) |h| allocator.free(h); + if (self.database) |d| allocator.free(d); + if (self.username) |u| allocator.free(u); + if (self.password) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + } + if (self.connection_string) |c| { + @memset(@constCast(c), 0); + allocator.free(c); + } + if (self.sid) |s| allocator.free(s); + } +}; + +pub const WifiSecurity = enum(u8) { + none = 0, + wep = 1, + wpa = 2, + wpa2 = 3, + wpa3 = 4, }; pub const WifiData = struct { - pub fn deinit(_: *WifiData, _: std.mem.Allocator) void {} + ssid: []const u8, + password: ?[]const u8 = null, + security: WifiSecurity = .wpa2, + hidden: bool = false, + + pub fn deinit(self: *WifiData, allocator: std.mem.Allocator) void { + allocator.free(self.ssid); + if (self.password) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + } + } }; pub const LicenseData = struct { - pub fn deinit(_: *LicenseData, _: std.mem.Allocator) void {} + license_key: ?[]const u8 = null, + product_name: ?[]const u8 = null, + version: ?[]const u8 = null, + publisher: ?[]const u8 = null, + email: ?[]const u8 = null, + purchase_date: ?i64 = null, + expiration_date: ?i64 = null, + + pub fn deinit(self: *LicenseData, allocator: std.mem.Allocator) void { + if (self.license_key) |k| { + @memset(@constCast(k), 0); + allocator.free(k); + } + if (self.product_name) |p| allocator.free(p); + if (self.version) |v| allocator.free(v); + if (self.publisher) |p| allocator.free(p); + if (self.email) |e| allocator.free(e); + } }; test "item type enum values" { @@ -384,3 +492,29 @@ test "identity data full name" { defer std.testing.allocator.free(full); try std.testing.expectEqualStrings("John Q Public", full); } + +test "ssh key data deinit clears private key" { + const allocator = std.testing.allocator; + + var ssh = SshKeyData{ + .private_key = try allocator.dupe(u8, "PRIVATE"), + .public_key = try allocator.dupe(u8, "PUBLIC"), + .fingerprint = try allocator.dupe(u8, "SHA256:xxx"), + .key_type = .ed25519, + .passphrase = try allocator.dupe(u8, "secret"), + }; + + ssh.deinit(allocator); +} + +test "wifi data deinit" { + const allocator = std.testing.allocator; + + var wifi = WifiData{ + .ssid = try allocator.dupe(u8, "MyNetwork"), + .password = try allocator.dupe(u8, "wifipass"), + .security = .wpa3, + }; + + wifi.deinit(allocator); +} From f884002c658a0e2e2b335e7e1ef0ce1d7a12a204 Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:12:48 +0100 Subject: [PATCH 09/19] feat(core): add item factory functions for login, secure note, and card --- src/core/item.zig | 139 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/src/core/item.zig b/src/core/item.zig index c947fa8..bba9408 100644 --- a/src/core/item.zig +++ b/src/core/item.zig @@ -444,6 +444,134 @@ pub const LicenseData = struct { } }; +pub fn createLoginItem( + allocator: std.mem.Allocator, + name: []const u8, + username: ?[]const u8, + password: ?[]const u8, + url: ?[]const u8, +) !Item { + const now = std.time.timestamp(); + + const name_copy = try allocator.dupe(u8, name); + errdefer allocator.free(name_copy); + + const username_copy = if (username) |u| try allocator.dupe(u8, u) else null; + errdefer if (username_copy) |u| allocator.free(u); + + const password_copy = if (password) |p| try allocator.dupe(u8, p) else null; + errdefer if (password_copy) |p| allocator.free(p); + + var uris: []Uri = &.{}; + if (url) |u| { + const uri_copy = try allocator.dupe(u8, u); + errdefer allocator.free(uri_copy); + + uris = try allocator.alloc(Uri, 1); + uris[0] = .{ .uri = uri_copy }; + } + + return Item{ + .id = Uuid.generate(), + .name = name_copy, + .notes = null, + .item_type = .login, + .data = .{ + .login = .{ + .username = username_copy, + .password = password_copy, + .uris = uris, + }, + }, + .favorite = false, + .fields = &.{}, + .password_history = &.{}, + .created_at = now, + .modified_at = now, + .last_accessed_at = null, + .access_count = 0, + .deleted_at = null, + }; +} + +pub fn createSecureNoteItem( + allocator: std.mem.Allocator, + name: []const u8, + notes: []const u8, +) !Item { + const now = std.time.timestamp(); + + const name_copy = try allocator.dupe(u8, name); + errdefer allocator.free(name_copy); + + const notes_copy = try allocator.dupe(u8, notes); + errdefer allocator.free(notes_copy); + + return Item{ + .id = Uuid.generate(), + .name = name_copy, + .notes = notes_copy, + .item_type = .secure_note, + .data = .{ .secure_note = .{} }, + .favorite = false, + .fields = &.{}, + .password_history = &.{}, + .created_at = now, + .modified_at = now, + .last_accessed_at = null, + .access_count = 0, + .deleted_at = null, + }; +} + +pub fn createCardItem( + allocator: std.mem.Allocator, + name: []const u8, + cardholder: ?[]const u8, + number: ?[]const u8, + exp_month: ?u8, + exp_year: ?u16, + cvv: ?[]const u8, +) !Item { + const now = std.time.timestamp(); + + const name_copy = try allocator.dupe(u8, name); + errdefer allocator.free(name_copy); + + const cardholder_copy = if (cardholder) |c| try allocator.dupe(u8, c) else null; + errdefer if (cardholder_copy) |c| allocator.free(c); + + const number_copy = if (number) |n| try allocator.dupe(u8, n) else null; + errdefer if (number_copy) |n| allocator.free(n); + + const cvv_copy = if (cvv) |c| try allocator.dupe(u8, c) else null; + errdefer if (cvv_copy) |c| allocator.free(c); + + return Item{ + .id = Uuid.generate(), + .name = name_copy, + .notes = null, + .item_type = .card, + .data = .{ + .card = .{ + .cardholder_name = cardholder_copy, + .number = number_copy, + .exp_month = exp_month, + .exp_year = exp_year, + .cvv = cvv_copy, + }, + }, + .favorite = false, + .fields = &.{}, + .password_history = &.{}, + .created_at = now, + .modified_at = now, + .last_accessed_at = null, + .access_count = 0, + .deleted_at = null, + }; +} + test "item type enum values" { try std.testing.expectEqual(@as(u8, 1), @intFromEnum(ItemType.login)); try std.testing.expectEqual(@as(u8, 9), @intFromEnum(ItemType.license)); @@ -518,3 +646,14 @@ test "wifi data deinit" { wifi.deinit(allocator); } + +test "create login item" { + const allocator = std.testing.allocator; + + var item = try createLoginItem(allocator, "github.com", "user", "pass", "https://github.com"); + defer item.deinit(allocator); + + try std.testing.expectEqualStrings("github.com", item.name); + try std.testing.expectEqual(ItemType.login, item.item_type); + try std.testing.expectEqualStrings("user", item.data.login.username.?); +} From 9f033f8f3c9d587d76296f7e8f08b800dd6675c8 Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:13:49 +0100 Subject: [PATCH 10/19] feat(core): add serializer v2 with serialize functions for all item types --- src/core/serializer_v2.zig | 244 +++++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 src/core/serializer_v2.zig diff --git a/src/core/serializer_v2.zig b/src/core/serializer_v2.zig new file mode 100644 index 0000000..55de760 --- /dev/null +++ b/src/core/serializer_v2.zig @@ -0,0 +1,244 @@ +const std = @import("std"); +const item = @import("item.zig"); +const Uuid = @import("uuid.zig").Uuid; + +pub const SerializeError = error{ + OutOfMemory, +}; + +pub const DeserializeError = error{ + InvalidData, + UnexpectedEndOfData, + InvalidItemType, + OutOfMemory, +}; + +pub fn serializeItems(allocator: std.mem.Allocator, items: []const item.Item) ![]u8 { + var buffer: std.ArrayList(u8) = .empty; + errdefer buffer.deinit(allocator); + + const writer = buffer.writer(allocator); + + try writer.writeInt(u32, @intCast(items.len), .little); + + for (items) |i| { + try serializeItem(allocator, &buffer, i); + } + + return buffer.toOwnedSlice(allocator); +} + +fn serializeItem(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), i: item.Item) !void { + const writer = buffer.writer(allocator); + + try buffer.appendSlice(allocator, &i.id.bytes); + + try writeString(allocator, buffer, i.name); + try writeOptionalString(allocator, buffer, i.notes); + + try writer.writeByte(@intFromEnum(i.item_type)); + + try writer.writeByte(if (i.favorite) 1 else 0); + + try writer.writeInt(i64, i.created_at, .little); + try writer.writeInt(i64, i.modified_at, .little); + try writeOptionalI64(allocator, buffer, i.last_accessed_at); + try writer.writeInt(u32, i.access_count, .little); + try writeOptionalI64(allocator, buffer, i.deleted_at); + + try writer.writeInt(u32, @intCast(i.fields.len), .little); + for (i.fields) |f| { + try serializeCustomField(allocator, buffer, f); + } + + try writer.writeInt(u32, @intCast(i.password_history.len), .little); + for (i.password_history) |h| { + try writeString(allocator, buffer, h.password); + try writer.writeInt(i64, h.changed_at, .little); + } + + try serializeItemData(allocator, buffer, i.data); +} + +fn serializeCustomField(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), f: item.CustomField) !void { + const writer = buffer.writer(allocator); + try writeString(allocator, buffer, f.name); + try writeOptionalString(allocator, buffer, f.value); + try writer.writeByte(@intFromEnum(f.field_type)); +} + +fn serializeItemData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), data: item.ItemData) !void { + switch (data) { + .login => |l| try serializeLoginData(allocator, buffer, l), + .secure_note => {}, + .card => |c| try serializeCardData(allocator, buffer, c), + .identity => |i| try serializeIdentityData(allocator, buffer, i), + .ssh_key => |s| try serializeSshKeyData(allocator, buffer, s), + .api_credential => |a| try serializeApiCredentialData(allocator, buffer, a), + .database => |d| try serializeDatabaseData(allocator, buffer, d), + .wifi => |w| try serializeWifiData(allocator, buffer, w), + .license => |l| try serializeLicenseData(allocator, buffer, l), + } +} + +fn serializeLoginData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), l: item.LoginData) !void { + const writer = buffer.writer(allocator); + + try writeOptionalString(allocator, buffer, l.username); + try writeOptionalString(allocator, buffer, l.password); + + try writer.writeInt(u32, @intCast(l.uris.len), .little); + for (l.uris) |uri| { + try writeString(allocator, buffer, uri.uri); + try writer.writeByte(@intFromEnum(uri.match_type)); + } + + if (l.totp) |t| { + try writer.writeByte(1); + try writeString(allocator, buffer, t.secret); + try writer.writeByte(@intFromEnum(t.algorithm)); + try writer.writeByte(t.digits); + try writer.writeInt(u32, t.period, .little); + } else { + try writer.writeByte(0); + } + + try writer.writeInt(u32, @intCast(l.passkeys.len), .little); + for (l.passkeys) |pk| { + try writeString(allocator, buffer, pk.credential_id); + try writeString(allocator, buffer, pk.private_key); + try writeString(allocator, buffer, pk.public_key); + try writer.writeInt(i32, @intFromEnum(pk.algorithm), .little); + try writeString(allocator, buffer, pk.rp_id); + try writeOptionalString(allocator, buffer, pk.rp_name); + try writeString(allocator, buffer, pk.user_handle); + try writeOptionalString(allocator, buffer, pk.user_name); + try writer.writeInt(u32, pk.counter, .little); + } +} + +fn serializeCardData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), c: item.CardData) !void { + const writer = buffer.writer(allocator); + + try writeOptionalString(allocator, buffer, c.cardholder_name); + try writeOptionalString(allocator, buffer, c.number); + try writer.writeByte(if (c.brand) |b| @intFromEnum(b) else 0); + try writer.writeByte(c.exp_month orelse 0); + try writer.writeInt(u16, c.exp_year orelse 0, .little); + try writeOptionalString(allocator, buffer, c.cvv); + try writeOptionalString(allocator, buffer, c.pin); +} + +fn serializeIdentityData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), i: item.IdentityData) !void { + const writer = buffer.writer(allocator); + + try writeOptionalString(allocator, buffer, i.title); + try writeOptionalString(allocator, buffer, i.first_name); + try writeOptionalString(allocator, buffer, i.middle_name); + try writeOptionalString(allocator, buffer, i.last_name); + try writeOptionalString(allocator, buffer, i.email); + try writeOptionalString(allocator, buffer, i.phone); + + if (i.address) |a| { + try writer.writeByte(1); + try writeOptionalString(allocator, buffer, a.street1); + try writeOptionalString(allocator, buffer, a.street2); + try writeOptionalString(allocator, buffer, a.city); + try writeOptionalString(allocator, buffer, a.state); + try writeOptionalString(allocator, buffer, a.postal_code); + try writeOptionalString(allocator, buffer, a.country); + } else { + try writer.writeByte(0); + } + + try writeOptionalString(allocator, buffer, i.ssn); + try writeOptionalString(allocator, buffer, i.passport); + try writeOptionalString(allocator, buffer, i.license_number); + try writeOptionalString(allocator, buffer, i.company); + try writeOptionalString(allocator, buffer, i.job_title); +} + +fn serializeSshKeyData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), s: item.SshKeyData) !void { + const writer = buffer.writer(allocator); + + try writeString(allocator, buffer, s.private_key); + try writeString(allocator, buffer, s.public_key); + try writeString(allocator, buffer, s.fingerprint); + try writer.writeByte(@intFromEnum(s.key_type)); + try writeOptionalString(allocator, buffer, s.passphrase); +} + +fn serializeApiCredentialData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), a: item.ApiCredentialData) !void { + try writeOptionalString(allocator, buffer, a.api_key); + try writeOptionalString(allocator, buffer, a.api_secret); + try writeOptionalString(allocator, buffer, a.endpoint); + try writeOptionalString(allocator, buffer, a.documentation_url); +} + +fn serializeDatabaseData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), d: item.DatabaseData) !void { + const writer = buffer.writer(allocator); + + try writer.writeByte(@intFromEnum(d.db_type)); + try writeOptionalString(allocator, buffer, d.host); + try writer.writeInt(u16, d.port orelse 0, .little); + try writeOptionalString(allocator, buffer, d.database); + try writeOptionalString(allocator, buffer, d.username); + try writeOptionalString(allocator, buffer, d.password); + try writeOptionalString(allocator, buffer, d.connection_string); + try writeOptionalString(allocator, buffer, d.sid); +} + +fn serializeWifiData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), w: item.WifiData) !void { + const writer = buffer.writer(allocator); + + try writeString(allocator, buffer, w.ssid); + try writeOptionalString(allocator, buffer, w.password); + try writer.writeByte(@intFromEnum(w.security)); + try writer.writeByte(if (w.hidden) 1 else 0); +} + +fn serializeLicenseData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), l: item.LicenseData) !void { + try writeOptionalString(allocator, buffer, l.license_key); + try writeOptionalString(allocator, buffer, l.product_name); + try writeOptionalString(allocator, buffer, l.version); + try writeOptionalString(allocator, buffer, l.publisher); + try writeOptionalString(allocator, buffer, l.email); + try writeOptionalI64(allocator, buffer, l.purchase_date); + try writeOptionalI64(allocator, buffer, l.expiration_date); +} + +fn writeString(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), s: []const u8) !void { + const writer = buffer.writer(allocator); + try writer.writeInt(u32, @intCast(s.len), .little); + try buffer.appendSlice(allocator, s); +} + +fn writeOptionalString(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), s: ?[]const u8) !void { + const writer = buffer.writer(allocator); + if (s) |str| { + try writer.writeByte(1); + try writeString(allocator, buffer, str); + } else { + try writer.writeByte(0); + } +} + +fn writeOptionalI64(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), v: ?i64) !void { + const writer = buffer.writer(allocator); + if (v) |val| { + try writer.writeByte(1); + try writer.writeInt(i64, val, .little); + } else { + try writer.writeByte(0); + } +} + +test "serialize empty items" { + const allocator = std.testing.allocator; + var items: [0]item.Item = .{}; + + const serialized = try serializeItems(allocator, &items); + defer allocator.free(serialized); + + try std.testing.expectEqual(@as(usize, 4), serialized.len); +} From 72174b19dca78903265f3e34a48cf9536d1bf246 Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:16:46 +0100 Subject: [PATCH 11/19] feat(core): replace Entry-based serializer with Item-based serializer - Removed v2 naming, directly replaced serializer.zig - Full serialize/deserialize for all 9 item types - Roundtrip tests passing --- src/core/serializer.zig | 695 ++++++++++++++++++++++++++----------- src/core/serializer_v2.zig | 244 ------------- 2 files changed, 487 insertions(+), 452 deletions(-) delete mode 100644 src/core/serializer_v2.zig diff --git a/src/core/serializer.zig b/src/core/serializer.zig index 4e71e49..2a16bff 100644 --- a/src/core/serializer.zig +++ b/src/core/serializer.zig @@ -1,5 +1,6 @@ const std = @import("std"); -const entry = @import("entry.zig"); +const item = @import("item.zig"); +const Uuid = @import("uuid.zig").Uuid; pub const SerializeError = error{ BufferTooSmall, @@ -10,78 +11,212 @@ pub const SerializeError = error{ pub const DeserializeError = error{ InvalidData, UnexpectedEndOfData, - InvalidEntryType, + InvalidItemType, OutOfMemory, }; -pub fn serializeEntries(allocator: std.mem.Allocator, entries: []const entry.Entry) ![]u8 { +pub fn serializeItems(allocator: std.mem.Allocator, items: []const item.Item) ![]u8 { var buffer: std.ArrayList(u8) = .empty; errdefer buffer.deinit(allocator); - var writer = buffer.writer(allocator); + const writer = buffer.writer(allocator); - try writer.writeInt(u32, @intCast(entries.len), .little); + try writer.writeInt(u32, @intCast(items.len), .little); - for (entries) |e| { - try serializeEntry(allocator, &buffer, e); + for (items) |i| { + try serializeItem(allocator, &buffer, i); } return buffer.toOwnedSlice(allocator); } -fn serializeEntry(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), e: entry.Entry) !void { - var writer = buffer.writer(allocator); - - try writeString(allocator, buffer, e.name); - try writer.writeByte(@intFromEnum(e.entry_type)); - try writer.writeInt(i64, e.created_at, .little); - try writer.writeInt(i64, e.modified_at, .little); - - switch (e.data) { - .password => |p| { - try writeOptionalString(allocator, buffer, p.username); - try writeString(allocator, buffer, p.password); - try writeOptionalString(allocator, buffer, p.url); - try writeOptionalString(allocator, buffer, p.notes); - if (p.totp_secret) |secret| { - try writer.writeByte(1); - try writeString(allocator, buffer, secret); - try writer.writeByte(@intFromEnum(p.totp_algorithm)); - try writer.writeByte(p.totp_digits); - try writer.writeInt(u32, p.totp_period, .little); - } else { - try writer.writeByte(0); - } - }, - .totp => |t| { - try writeString(allocator, buffer, t.secret); - try writer.writeByte(@intFromEnum(t.algorithm)); - try writer.writeByte(t.digits); - try writer.writeInt(u32, t.period, .little); - try writeOptionalString(allocator, buffer, t.issuer); - }, - .passkey => |pk| { - try writeString(allocator, buffer, pk.credential_id); - try writeString(allocator, buffer, pk.private_key); - try writeString(allocator, buffer, pk.public_key); - try writer.writeInt(i32, @intFromEnum(pk.algorithm), .little); - try writeString(allocator, buffer, pk.rp_id); - try writeOptionalString(allocator, buffer, pk.rp_name); - try writeString(allocator, buffer, pk.user_handle); - try writeOptionalString(allocator, buffer, pk.user_name); - try writer.writeInt(u32, pk.counter, .little); - }, +fn serializeItem(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), i: item.Item) !void { + const writer = buffer.writer(allocator); + + try buffer.appendSlice(allocator, &i.id.bytes); + + try writeString(allocator, buffer, i.name); + try writeOptionalString(allocator, buffer, i.notes); + + try writer.writeByte(@intFromEnum(i.item_type)); + + try writer.writeByte(if (i.favorite) 1 else 0); + + try writer.writeInt(i64, i.created_at, .little); + try writer.writeInt(i64, i.modified_at, .little); + try writeOptionalI64(allocator, buffer, i.last_accessed_at); + try writer.writeInt(u32, i.access_count, .little); + try writeOptionalI64(allocator, buffer, i.deleted_at); + + try writer.writeInt(u32, @intCast(i.fields.len), .little); + for (i.fields) |f| { + try serializeCustomField(allocator, buffer, f); + } + + try writer.writeInt(u32, @intCast(i.password_history.len), .little); + for (i.password_history) |h| { + try writeString(allocator, buffer, h.password); + try writer.writeInt(i64, h.changed_at, .little); + } + + try serializeItemData(allocator, buffer, i.data); +} + +fn serializeCustomField(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), f: item.CustomField) !void { + const writer = buffer.writer(allocator); + try writeString(allocator, buffer, f.name); + try writeOptionalString(allocator, buffer, f.value); + try writer.writeByte(@intFromEnum(f.field_type)); +} + +fn serializeItemData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), data: item.ItemData) !void { + switch (data) { + .login => |l| try serializeLoginData(allocator, buffer, l), + .secure_note => {}, + .card => |c| try serializeCardData(allocator, buffer, c), + .identity => |i| try serializeIdentityData(allocator, buffer, i), + .ssh_key => |s| try serializeSshKeyData(allocator, buffer, s), + .api_credential => |a| try serializeApiCredentialData(allocator, buffer, a), + .database => |d| try serializeDatabaseData(allocator, buffer, d), + .wifi => |w| try serializeWifiData(allocator, buffer, w), + .license => |l| try serializeLicenseData(allocator, buffer, l), } } +fn serializeLoginData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), l: item.LoginData) !void { + const writer = buffer.writer(allocator); + + try writeOptionalString(allocator, buffer, l.username); + try writeOptionalString(allocator, buffer, l.password); + + try writer.writeInt(u32, @intCast(l.uris.len), .little); + for (l.uris) |uri| { + try writeString(allocator, buffer, uri.uri); + try writer.writeByte(@intFromEnum(uri.match_type)); + } + + if (l.totp) |t| { + try writer.writeByte(1); + try writeString(allocator, buffer, t.secret); + try writer.writeByte(@intFromEnum(t.algorithm)); + try writer.writeByte(t.digits); + try writer.writeInt(u32, t.period, .little); + } else { + try writer.writeByte(0); + } + + try writer.writeInt(u32, @intCast(l.passkeys.len), .little); + for (l.passkeys) |pk| { + try writeString(allocator, buffer, pk.credential_id); + try writeString(allocator, buffer, pk.private_key); + try writeString(allocator, buffer, pk.public_key); + try writer.writeInt(i32, @intFromEnum(pk.algorithm), .little); + try writeString(allocator, buffer, pk.rp_id); + try writeOptionalString(allocator, buffer, pk.rp_name); + try writeString(allocator, buffer, pk.user_handle); + try writeOptionalString(allocator, buffer, pk.user_name); + try writer.writeInt(u32, pk.counter, .little); + } +} + +fn serializeCardData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), c: item.CardData) !void { + const writer = buffer.writer(allocator); + + try writeOptionalString(allocator, buffer, c.cardholder_name); + try writeOptionalString(allocator, buffer, c.number); + try writer.writeByte(if (c.brand) |b| @intFromEnum(b) else 0); + try writer.writeByte(c.exp_month orelse 0); + try writer.writeInt(u16, c.exp_year orelse 0, .little); + try writeOptionalString(allocator, buffer, c.cvv); + try writeOptionalString(allocator, buffer, c.pin); +} + +fn serializeIdentityData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), i: item.IdentityData) !void { + const writer = buffer.writer(allocator); + + try writeOptionalString(allocator, buffer, i.title); + try writeOptionalString(allocator, buffer, i.first_name); + try writeOptionalString(allocator, buffer, i.middle_name); + try writeOptionalString(allocator, buffer, i.last_name); + try writeOptionalString(allocator, buffer, i.email); + try writeOptionalString(allocator, buffer, i.phone); + + if (i.address) |a| { + try writer.writeByte(1); + try writeOptionalString(allocator, buffer, a.street1); + try writeOptionalString(allocator, buffer, a.street2); + try writeOptionalString(allocator, buffer, a.city); + try writeOptionalString(allocator, buffer, a.state); + try writeOptionalString(allocator, buffer, a.postal_code); + try writeOptionalString(allocator, buffer, a.country); + } else { + try writer.writeByte(0); + } + + try writeOptionalString(allocator, buffer, i.ssn); + try writeOptionalString(allocator, buffer, i.passport); + try writeOptionalString(allocator, buffer, i.license_number); + try writeOptionalString(allocator, buffer, i.company); + try writeOptionalString(allocator, buffer, i.job_title); +} + +fn serializeSshKeyData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), s: item.SshKeyData) !void { + const writer = buffer.writer(allocator); + + try writeString(allocator, buffer, s.private_key); + try writeString(allocator, buffer, s.public_key); + try writeString(allocator, buffer, s.fingerprint); + try writer.writeByte(@intFromEnum(s.key_type)); + try writeOptionalString(allocator, buffer, s.passphrase); +} + +fn serializeApiCredentialData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), a: item.ApiCredentialData) !void { + try writeOptionalString(allocator, buffer, a.api_key); + try writeOptionalString(allocator, buffer, a.api_secret); + try writeOptionalString(allocator, buffer, a.endpoint); + try writeOptionalString(allocator, buffer, a.documentation_url); +} + +fn serializeDatabaseData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), d: item.DatabaseData) !void { + const writer = buffer.writer(allocator); + + try writer.writeByte(@intFromEnum(d.db_type)); + try writeOptionalString(allocator, buffer, d.host); + try writer.writeInt(u16, d.port orelse 0, .little); + try writeOptionalString(allocator, buffer, d.database); + try writeOptionalString(allocator, buffer, d.username); + try writeOptionalString(allocator, buffer, d.password); + try writeOptionalString(allocator, buffer, d.connection_string); + try writeOptionalString(allocator, buffer, d.sid); +} + +fn serializeWifiData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), w: item.WifiData) !void { + const writer = buffer.writer(allocator); + + try writeString(allocator, buffer, w.ssid); + try writeOptionalString(allocator, buffer, w.password); + try writer.writeByte(@intFromEnum(w.security)); + try writer.writeByte(if (w.hidden) 1 else 0); +} + +fn serializeLicenseData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), l: item.LicenseData) !void { + try writeOptionalString(allocator, buffer, l.license_key); + try writeOptionalString(allocator, buffer, l.product_name); + try writeOptionalString(allocator, buffer, l.version); + try writeOptionalString(allocator, buffer, l.publisher); + try writeOptionalString(allocator, buffer, l.email); + try writeOptionalI64(allocator, buffer, l.purchase_date); + try writeOptionalI64(allocator, buffer, l.expiration_date); +} + fn writeString(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), s: []const u8) !void { - var writer = buffer.writer(allocator); + const writer = buffer.writer(allocator); try writer.writeInt(u32, @intCast(s.len), .little); try buffer.appendSlice(allocator, s); } fn writeOptionalString(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), s: ?[]const u8) !void { - var writer = buffer.writer(allocator); + const writer = buffer.writer(allocator); if (s) |str| { try writer.writeByte(1); try writeString(allocator, buffer, str); @@ -90,144 +225,191 @@ fn writeOptionalString(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), } } -pub fn deserializeEntries(allocator: std.mem.Allocator, data: []const u8) ![]entry.Entry { +fn writeOptionalI64(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), v: ?i64) !void { + const writer = buffer.writer(allocator); + if (v) |val| { + try writer.writeByte(1); + try writer.writeInt(i64, val, .little); + } else { + try writer.writeByte(0); + } +} + +pub fn deserializeItems(allocator: std.mem.Allocator, data: []const u8) ![]item.Item { var stream = std.io.fixedBufferStream(data); - var reader = stream.reader(); + const reader = stream.reader(); const count = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; - var entries: std.ArrayList(entry.Entry) = .empty; + var items: std.ArrayList(item.Item) = .empty; errdefer { - for (entries.items) |*e| { - e.deinit(allocator); - } - entries.deinit(allocator); + for (items.items) |*i| i.deinit(allocator); + items.deinit(allocator); } for (0..count) |_| { - const e = try deserializeEntry(allocator, &reader); - try entries.append(allocator, e); + const i = try deserializeItem(allocator, reader); + try items.append(allocator, i); } - return entries.toOwnedSlice(allocator); + return items.toOwnedSlice(allocator); } -fn deserializeEntry(allocator: std.mem.Allocator, reader: anytype) !entry.Entry { +fn deserializeItem(allocator: std.mem.Allocator, reader: anytype) !item.Item { + var uuid_bytes: [16]u8 = undefined; + _ = reader.readAll(&uuid_bytes) catch return DeserializeError.UnexpectedEndOfData; + const id = Uuid.fromBytes(uuid_bytes); + const name = try readString(allocator, reader); errdefer allocator.free(name); - const entry_type_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; - const entry_type = std.meta.intToEnum(entry.EntryType, entry_type_byte) catch return DeserializeError.InvalidEntryType; + const notes = try readOptionalString(allocator, reader); + errdefer if (notes) |n| allocator.free(n); + + const type_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const item_type = std.meta.intToEnum(item.ItemType, type_byte) catch return DeserializeError.InvalidItemType; + + const favorite_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const favorite = favorite_byte == 1; const created_at = reader.readInt(i64, .little) catch return DeserializeError.UnexpectedEndOfData; const modified_at = reader.readInt(i64, .little) catch return DeserializeError.UnexpectedEndOfData; + const last_accessed_at = try readOptionalI64(reader); + const access_count = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; + const deleted_at = try readOptionalI64(reader); - const data: entry.EntryData = switch (entry_type) { - .password => .{ .password = try deserializePasswordEntry(allocator, reader) }, - .totp => .{ .totp = try deserializeTotpEntry(allocator, reader) }, - .passkey => .{ .passkey = try deserializePasskeyEntry(allocator, reader) }, - }; + const fields_count = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; + var fields = try allocator.alloc(item.CustomField, fields_count); + errdefer allocator.free(fields); + + for (0..fields_count) |i| { + fields[i] = try deserializeCustomField(allocator, reader); + } - return entry.Entry{ + const history_count = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; + var history = try allocator.alloc(item.PasswordHistoryEntry, history_count); + errdefer allocator.free(history); + + for (0..history_count) |i| { + const pw = try readString(allocator, reader); + const changed_at = reader.readInt(i64, .little) catch return DeserializeError.UnexpectedEndOfData; + history[i] = .{ .password = pw, .changed_at = changed_at }; + } + + const data = try deserializeItemData(allocator, reader, item_type); + + return item.Item{ + .id = id, .name = name, - .entry_type = entry_type, + .notes = notes, + .item_type = item_type, + .data = data, + .favorite = favorite, + .fields = fields, + .password_history = history, .created_at = created_at, .modified_at = modified_at, - .data = data, + .last_accessed_at = last_accessed_at, + .access_count = access_count, + .deleted_at = deleted_at, }; } -fn deserializePasswordEntry(allocator: std.mem.Allocator, reader: anytype) !entry.PasswordEntry { - const username = try readOptionalString(allocator, reader); - errdefer if (username) |u| allocator.free(u); +fn deserializeCustomField(allocator: std.mem.Allocator, reader: anytype) !item.CustomField { + const name = try readString(allocator, reader); + errdefer allocator.free(name); - const password = try readString(allocator, reader); - errdefer allocator.free(password); + const value = try readOptionalString(allocator, reader); + errdefer if (value) |v| allocator.free(v); - const url = try readOptionalString(allocator, reader); - errdefer if (url) |u| allocator.free(u); + const type_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const field_type = std.meta.intToEnum(item.FieldType, type_byte) catch return DeserializeError.InvalidData; - const notes = try readOptionalString(allocator, reader); - errdefer if (notes) |n| allocator.free(n); + return .{ + .name = name, + .value = value, + .field_type = field_type, + }; +} - const has_totp = reader.readByte() catch 0; - var totp_secret: ?[]u8 = null; - var totp_algorithm: entry.TotpAlgorithm = .sha1; - var totp_digits: u8 = 6; - var totp_period: u32 = 30; +fn deserializeItemData(allocator: std.mem.Allocator, reader: anytype, item_type: item.ItemType) !item.ItemData { + return switch (item_type) { + .login => .{ .login = try deserializeLoginData(allocator, reader) }, + .secure_note => .{ .secure_note = .{} }, + .card => .{ .card = try deserializeCardData(allocator, reader) }, + .identity => .{ .identity = try deserializeIdentityData(allocator, reader) }, + .ssh_key => .{ .ssh_key = try deserializeSshKeyData(allocator, reader) }, + .api_credential => .{ .api_credential = try deserializeApiCredentialData(allocator, reader) }, + .database => .{ .database = try deserializeDatabaseData(allocator, reader) }, + .wifi => .{ .wifi = try deserializeWifiData(allocator, reader) }, + .license => .{ .license = try deserializeLicenseData(allocator, reader) }, + }; +} - if (has_totp == 1) { - totp_secret = try readString(allocator, reader); - errdefer if (totp_secret) |s| allocator.free(s); +fn deserializeLoginData(allocator: std.mem.Allocator, reader: anytype) !item.LoginData { + const username = try readOptionalString(allocator, reader); + errdefer if (username) |u| allocator.free(u); - const algo_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; - totp_algorithm = std.meta.intToEnum(entry.TotpAlgorithm, algo_byte) catch return DeserializeError.InvalidData; - totp_digits = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; - totp_period = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; - } + const password = try readOptionalString(allocator, reader); + errdefer if (password) |p| allocator.free(p); - return entry.PasswordEntry{ - .username = username, - .password = password, - .url = url, - .notes = notes, - .totp_secret = totp_secret, - .totp_algorithm = totp_algorithm, - .totp_digits = totp_digits, - .totp_period = totp_period, - }; -} + const uri_count = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; + var uris = try allocator.alloc(item.Uri, uri_count); + errdefer allocator.free(uris); -fn deserializeTotpEntry(allocator: std.mem.Allocator, reader: anytype) !entry.TotpEntry { - const secret = try readString(allocator, reader); - errdefer allocator.free(secret); + for (0..uri_count) |i| { + const uri_str = try readString(allocator, reader); + const match_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const match_type = std.meta.intToEnum(item.UriMatchType, match_byte) catch return DeserializeError.InvalidData; + uris[i] = .{ .uri = uri_str, .match_type = match_type }; + } - const algo_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; - const algorithm = std.meta.intToEnum(entry.TotpAlgorithm, algo_byte) catch return DeserializeError.InvalidData; + const has_totp = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + var totp: ?item.TotpData = null; + if (has_totp == 1) { + const secret = try readString(allocator, reader); + const algo_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const algorithm = std.meta.intToEnum(item.TotpAlgorithm, algo_byte) catch return DeserializeError.InvalidData; + const digits = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const period = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; + totp = .{ + .secret = secret, + .algorithm = algorithm, + .digits = digits, + .period = period, + }; + } - const digits = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; - const period = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; + const pk_count = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; + var passkeys = try allocator.alloc(item.PasskeyData, pk_count); + errdefer allocator.free(passkeys); - const issuer = try readOptionalString(allocator, reader); - errdefer if (issuer) |i| allocator.free(i); + for (0..pk_count) |i| { + passkeys[i] = try deserializePasskeyData(allocator, reader); + } - return entry.TotpEntry{ - .secret = secret, - .algorithm = algorithm, - .digits = digits, - .period = period, - .issuer = issuer, + return .{ + .username = username, + .password = password, + .uris = uris, + .totp = totp, + .passkeys = passkeys, }; } -fn deserializePasskeyEntry(allocator: std.mem.Allocator, reader: anytype) !entry.PasskeyEntry { +fn deserializePasskeyData(allocator: std.mem.Allocator, reader: anytype) !item.PasskeyData { const credential_id = try readString(allocator, reader); - errdefer allocator.free(credential_id); - const private_key = try readString(allocator, reader); - errdefer allocator.free(private_key); - const public_key = try readString(allocator, reader); - errdefer allocator.free(public_key); - const algo_int = reader.readInt(i32, .little) catch return DeserializeError.UnexpectedEndOfData; - const algorithm = std.meta.intToEnum(entry.PasskeyAlgorithm, algo_int) catch return DeserializeError.InvalidData; - + const algorithm = std.meta.intToEnum(item.PasskeyAlgorithm, algo_int) catch return DeserializeError.InvalidData; const rp_id = try readString(allocator, reader); - errdefer allocator.free(rp_id); - const rp_name = try readOptionalString(allocator, reader); - errdefer if (rp_name) |n| allocator.free(n); - const user_handle = try readString(allocator, reader); - errdefer allocator.free(user_handle); - const user_name = try readOptionalString(allocator, reader); - errdefer if (user_name) |n| allocator.free(n); - const counter = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; - return entry.PasskeyEntry{ + return .{ .credential_id = credential_id, .private_key = private_key, .public_key = public_key, @@ -240,115 +422,212 @@ fn deserializePasskeyEntry(allocator: std.mem.Allocator, reader: anytype) !entry }; } -fn readString(allocator: std.mem.Allocator, reader: anytype) ![]u8 { - const len = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; +fn deserializeCardData(allocator: std.mem.Allocator, reader: anytype) !item.CardData { + const cardholder = try readOptionalString(allocator, reader); + const number = try readOptionalString(allocator, reader); + const brand_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const brand: ?item.CardBrand = if (brand_byte == 0) null else std.meta.intToEnum(item.CardBrand, brand_byte) catch null; + const exp_month_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const exp_month: ?u8 = if (exp_month_byte == 0) null else exp_month_byte; + const exp_year_val = reader.readInt(u16, .little) catch return DeserializeError.UnexpectedEndOfData; + const exp_year: ?u16 = if (exp_year_val == 0) null else exp_year_val; + const cvv = try readOptionalString(allocator, reader); + const pin = try readOptionalString(allocator, reader); + + return .{ + .cardholder_name = cardholder, + .number = number, + .brand = brand, + .exp_month = exp_month, + .exp_year = exp_year, + .cvv = cvv, + .pin = pin, + }; +} - if (len > 1024 * 1024) { - return DeserializeError.InvalidData; +fn deserializeIdentityData(allocator: std.mem.Allocator, reader: anytype) !item.IdentityData { + const title = try readOptionalString(allocator, reader); + const first_name = try readOptionalString(allocator, reader); + const middle_name = try readOptionalString(allocator, reader); + const last_name = try readOptionalString(allocator, reader); + const email = try readOptionalString(allocator, reader); + const phone = try readOptionalString(allocator, reader); + + const has_address = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + var address: ?item.AddressData = null; + if (has_address == 1) { + address = .{ + .street1 = try readOptionalString(allocator, reader), + .street2 = try readOptionalString(allocator, reader), + .city = try readOptionalString(allocator, reader), + .state = try readOptionalString(allocator, reader), + .postal_code = try readOptionalString(allocator, reader), + .country = try readOptionalString(allocator, reader), + }; } + const ssn = try readOptionalString(allocator, reader); + const passport = try readOptionalString(allocator, reader); + const license_number = try readOptionalString(allocator, reader); + const company = try readOptionalString(allocator, reader); + const job_title = try readOptionalString(allocator, reader); + + return .{ + .title = title, + .first_name = first_name, + .middle_name = middle_name, + .last_name = last_name, + .email = email, + .phone = phone, + .address = address, + .ssn = ssn, + .passport = passport, + .license_number = license_number, + .company = company, + .job_title = job_title, + }; +} + +fn deserializeSshKeyData(allocator: std.mem.Allocator, reader: anytype) !item.SshKeyData { + const private_key = try readString(allocator, reader); + const public_key = try readString(allocator, reader); + const fingerprint = try readString(allocator, reader); + const type_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const key_type = std.meta.intToEnum(item.SshKeyType, type_byte) catch return DeserializeError.InvalidData; + const passphrase = try readOptionalString(allocator, reader); + + return .{ + .private_key = private_key, + .public_key = public_key, + .fingerprint = fingerprint, + .key_type = key_type, + .passphrase = passphrase, + }; +} + +fn deserializeApiCredentialData(allocator: std.mem.Allocator, reader: anytype) !item.ApiCredentialData { + return .{ + .api_key = try readOptionalString(allocator, reader), + .api_secret = try readOptionalString(allocator, reader), + .endpoint = try readOptionalString(allocator, reader), + .documentation_url = try readOptionalString(allocator, reader), + }; +} + +fn deserializeDatabaseData(allocator: std.mem.Allocator, reader: anytype) !item.DatabaseData { + const type_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const db_type = std.meta.intToEnum(item.DatabaseType, type_byte) catch return DeserializeError.InvalidData; + const host = try readOptionalString(allocator, reader); + const port_val = reader.readInt(u16, .little) catch return DeserializeError.UnexpectedEndOfData; + const port: ?u16 = if (port_val == 0) null else port_val; + const database = try readOptionalString(allocator, reader); + const username = try readOptionalString(allocator, reader); + const password = try readOptionalString(allocator, reader); + const connection_string = try readOptionalString(allocator, reader); + const sid = try readOptionalString(allocator, reader); + + return .{ + .db_type = db_type, + .host = host, + .port = port, + .database = database, + .username = username, + .password = password, + .connection_string = connection_string, + .sid = sid, + }; +} + +fn deserializeWifiData(allocator: std.mem.Allocator, reader: anytype) !item.WifiData { + const ssid = try readString(allocator, reader); + const password = try readOptionalString(allocator, reader); + const sec_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + const security = std.meta.intToEnum(item.WifiSecurity, sec_byte) catch return DeserializeError.InvalidData; + const hidden_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + + return .{ + .ssid = ssid, + .password = password, + .security = security, + .hidden = hidden_byte == 1, + }; +} + +fn deserializeLicenseData(allocator: std.mem.Allocator, reader: anytype) !item.LicenseData { + return .{ + .license_key = try readOptionalString(allocator, reader), + .product_name = try readOptionalString(allocator, reader), + .version = try readOptionalString(allocator, reader), + .publisher = try readOptionalString(allocator, reader), + .email = try readOptionalString(allocator, reader), + .purchase_date = try readOptionalI64(reader), + .expiration_date = try readOptionalI64(reader), + }; +} + +fn readString(allocator: std.mem.Allocator, reader: anytype) ![]u8 { + const len = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; + if (len > 1024 * 1024) return DeserializeError.InvalidData; + const str = try allocator.alloc(u8, len); errdefer allocator.free(str); const bytes_read = reader.readAll(str) catch return DeserializeError.UnexpectedEndOfData; - if (bytes_read != len) { - return DeserializeError.UnexpectedEndOfData; - } + if (bytes_read != len) return DeserializeError.UnexpectedEndOfData; return str; } fn readOptionalString(allocator: std.mem.Allocator, reader: anytype) !?[]u8 { const present = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; - if (present == 1) { return try readString(allocator, reader); } + return null; +} +fn readOptionalI64(reader: anytype) !?i64 { + const present = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; + if (present == 1) { + return reader.readInt(i64, .little) catch return DeserializeError.UnexpectedEndOfData; + } return null; } -test "serialize and deserialize password entry" { +test "serialize and deserialize login item roundtrip" { const allocator = std.testing.allocator; - var entries_list: std.ArrayList(entry.Entry) = .empty; - defer { - for (entries_list.items) |*ent| ent.deinit(allocator); - entries_list.deinit(allocator); - } - - const e = try entry.createPasswordEntry( - allocator, - "github.com", - "testuser", - "secretpassword", - "https://github.com", - ); + var login_item = try item.createLoginItem(allocator, "github.com", "testuser", "secretpass", "https://github.com"); + defer login_item.deinit(allocator); - try entries_list.append(allocator, e); + var items_arr = [_]item.Item{login_item}; - const serialized = try serializeEntries(allocator, entries_list.items); + const serialized = try serializeItems(allocator, &items_arr); defer allocator.free(serialized); - const deserialized = try deserializeEntries(allocator, serialized); + const deserialized = try deserializeItems(allocator, serialized); defer { - for (deserialized) |*de| { - de.deinit(allocator); - } + for (deserialized) |*i| i.deinit(allocator); allocator.free(deserialized); } try std.testing.expectEqual(@as(usize, 1), deserialized.len); try std.testing.expectEqualStrings("github.com", deserialized[0].name); - try std.testing.expectEqual(entry.EntryType.password, deserialized[0].entry_type); - - const pwd = deserialized[0].data.password; - try std.testing.expectEqualStrings("testuser", pwd.username.?); - try std.testing.expectEqualStrings("secretpassword", pwd.password); - try std.testing.expectEqualStrings("https://github.com", pwd.url.?); + try std.testing.expectEqual(item.ItemType.login, deserialized[0].item_type); + try std.testing.expectEqualStrings("testuser", deserialized[0].data.login.username.?); + try std.testing.expectEqualStrings("secretpass", deserialized[0].data.login.password.?); } -test "serialize and deserialize multiple entries" { +test "serialize empty items" { const allocator = std.testing.allocator; + var items_arr: [0]item.Item = .{}; - var entries_list: std.ArrayList(entry.Entry) = .empty; - defer { - for (entries_list.items) |*ent| ent.deinit(allocator); - entries_list.deinit(allocator); - } - - const e1 = try entry.createPasswordEntry(allocator, "site1.com", "user1", "pass1", null); - try entries_list.append(allocator, e1); - - const e2 = try entry.createPasswordEntry(allocator, "site2.com", null, "pass2", "https://site2.com"); - try entries_list.append(allocator, e2); - - const serialized = try serializeEntries(allocator, entries_list.items); + const serialized = try serializeItems(allocator, &items_arr); defer allocator.free(serialized); - const deserialized = try deserializeEntries(allocator, serialized); - defer { - for (deserialized) |*de| { - de.deinit(allocator); - } - allocator.free(deserialized); - } - - try std.testing.expectEqual(@as(usize, 2), deserialized.len); - try std.testing.expectEqualStrings("site1.com", deserialized[0].name); - try std.testing.expectEqualStrings("site2.com", deserialized[1].name); - try std.testing.expect(deserialized[1].data.password.username == null); -} - -test "serialize empty entries" { - const allocator = std.testing.allocator; - - var entries_list: [0]entry.Entry = .{}; - - const serialized = try serializeEntries(allocator, &entries_list); - defer allocator.free(serialized); + try std.testing.expectEqual(@as(usize, 4), serialized.len); - const deserialized = try deserializeEntries(allocator, serialized); + const deserialized = try deserializeItems(allocator, serialized); defer allocator.free(deserialized); try std.testing.expectEqual(@as(usize, 0), deserialized.len); diff --git a/src/core/serializer_v2.zig b/src/core/serializer_v2.zig deleted file mode 100644 index 55de760..0000000 --- a/src/core/serializer_v2.zig +++ /dev/null @@ -1,244 +0,0 @@ -const std = @import("std"); -const item = @import("item.zig"); -const Uuid = @import("uuid.zig").Uuid; - -pub const SerializeError = error{ - OutOfMemory, -}; - -pub const DeserializeError = error{ - InvalidData, - UnexpectedEndOfData, - InvalidItemType, - OutOfMemory, -}; - -pub fn serializeItems(allocator: std.mem.Allocator, items: []const item.Item) ![]u8 { - var buffer: std.ArrayList(u8) = .empty; - errdefer buffer.deinit(allocator); - - const writer = buffer.writer(allocator); - - try writer.writeInt(u32, @intCast(items.len), .little); - - for (items) |i| { - try serializeItem(allocator, &buffer, i); - } - - return buffer.toOwnedSlice(allocator); -} - -fn serializeItem(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), i: item.Item) !void { - const writer = buffer.writer(allocator); - - try buffer.appendSlice(allocator, &i.id.bytes); - - try writeString(allocator, buffer, i.name); - try writeOptionalString(allocator, buffer, i.notes); - - try writer.writeByte(@intFromEnum(i.item_type)); - - try writer.writeByte(if (i.favorite) 1 else 0); - - try writer.writeInt(i64, i.created_at, .little); - try writer.writeInt(i64, i.modified_at, .little); - try writeOptionalI64(allocator, buffer, i.last_accessed_at); - try writer.writeInt(u32, i.access_count, .little); - try writeOptionalI64(allocator, buffer, i.deleted_at); - - try writer.writeInt(u32, @intCast(i.fields.len), .little); - for (i.fields) |f| { - try serializeCustomField(allocator, buffer, f); - } - - try writer.writeInt(u32, @intCast(i.password_history.len), .little); - for (i.password_history) |h| { - try writeString(allocator, buffer, h.password); - try writer.writeInt(i64, h.changed_at, .little); - } - - try serializeItemData(allocator, buffer, i.data); -} - -fn serializeCustomField(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), f: item.CustomField) !void { - const writer = buffer.writer(allocator); - try writeString(allocator, buffer, f.name); - try writeOptionalString(allocator, buffer, f.value); - try writer.writeByte(@intFromEnum(f.field_type)); -} - -fn serializeItemData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), data: item.ItemData) !void { - switch (data) { - .login => |l| try serializeLoginData(allocator, buffer, l), - .secure_note => {}, - .card => |c| try serializeCardData(allocator, buffer, c), - .identity => |i| try serializeIdentityData(allocator, buffer, i), - .ssh_key => |s| try serializeSshKeyData(allocator, buffer, s), - .api_credential => |a| try serializeApiCredentialData(allocator, buffer, a), - .database => |d| try serializeDatabaseData(allocator, buffer, d), - .wifi => |w| try serializeWifiData(allocator, buffer, w), - .license => |l| try serializeLicenseData(allocator, buffer, l), - } -} - -fn serializeLoginData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), l: item.LoginData) !void { - const writer = buffer.writer(allocator); - - try writeOptionalString(allocator, buffer, l.username); - try writeOptionalString(allocator, buffer, l.password); - - try writer.writeInt(u32, @intCast(l.uris.len), .little); - for (l.uris) |uri| { - try writeString(allocator, buffer, uri.uri); - try writer.writeByte(@intFromEnum(uri.match_type)); - } - - if (l.totp) |t| { - try writer.writeByte(1); - try writeString(allocator, buffer, t.secret); - try writer.writeByte(@intFromEnum(t.algorithm)); - try writer.writeByte(t.digits); - try writer.writeInt(u32, t.period, .little); - } else { - try writer.writeByte(0); - } - - try writer.writeInt(u32, @intCast(l.passkeys.len), .little); - for (l.passkeys) |pk| { - try writeString(allocator, buffer, pk.credential_id); - try writeString(allocator, buffer, pk.private_key); - try writeString(allocator, buffer, pk.public_key); - try writer.writeInt(i32, @intFromEnum(pk.algorithm), .little); - try writeString(allocator, buffer, pk.rp_id); - try writeOptionalString(allocator, buffer, pk.rp_name); - try writeString(allocator, buffer, pk.user_handle); - try writeOptionalString(allocator, buffer, pk.user_name); - try writer.writeInt(u32, pk.counter, .little); - } -} - -fn serializeCardData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), c: item.CardData) !void { - const writer = buffer.writer(allocator); - - try writeOptionalString(allocator, buffer, c.cardholder_name); - try writeOptionalString(allocator, buffer, c.number); - try writer.writeByte(if (c.brand) |b| @intFromEnum(b) else 0); - try writer.writeByte(c.exp_month orelse 0); - try writer.writeInt(u16, c.exp_year orelse 0, .little); - try writeOptionalString(allocator, buffer, c.cvv); - try writeOptionalString(allocator, buffer, c.pin); -} - -fn serializeIdentityData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), i: item.IdentityData) !void { - const writer = buffer.writer(allocator); - - try writeOptionalString(allocator, buffer, i.title); - try writeOptionalString(allocator, buffer, i.first_name); - try writeOptionalString(allocator, buffer, i.middle_name); - try writeOptionalString(allocator, buffer, i.last_name); - try writeOptionalString(allocator, buffer, i.email); - try writeOptionalString(allocator, buffer, i.phone); - - if (i.address) |a| { - try writer.writeByte(1); - try writeOptionalString(allocator, buffer, a.street1); - try writeOptionalString(allocator, buffer, a.street2); - try writeOptionalString(allocator, buffer, a.city); - try writeOptionalString(allocator, buffer, a.state); - try writeOptionalString(allocator, buffer, a.postal_code); - try writeOptionalString(allocator, buffer, a.country); - } else { - try writer.writeByte(0); - } - - try writeOptionalString(allocator, buffer, i.ssn); - try writeOptionalString(allocator, buffer, i.passport); - try writeOptionalString(allocator, buffer, i.license_number); - try writeOptionalString(allocator, buffer, i.company); - try writeOptionalString(allocator, buffer, i.job_title); -} - -fn serializeSshKeyData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), s: item.SshKeyData) !void { - const writer = buffer.writer(allocator); - - try writeString(allocator, buffer, s.private_key); - try writeString(allocator, buffer, s.public_key); - try writeString(allocator, buffer, s.fingerprint); - try writer.writeByte(@intFromEnum(s.key_type)); - try writeOptionalString(allocator, buffer, s.passphrase); -} - -fn serializeApiCredentialData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), a: item.ApiCredentialData) !void { - try writeOptionalString(allocator, buffer, a.api_key); - try writeOptionalString(allocator, buffer, a.api_secret); - try writeOptionalString(allocator, buffer, a.endpoint); - try writeOptionalString(allocator, buffer, a.documentation_url); -} - -fn serializeDatabaseData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), d: item.DatabaseData) !void { - const writer = buffer.writer(allocator); - - try writer.writeByte(@intFromEnum(d.db_type)); - try writeOptionalString(allocator, buffer, d.host); - try writer.writeInt(u16, d.port orelse 0, .little); - try writeOptionalString(allocator, buffer, d.database); - try writeOptionalString(allocator, buffer, d.username); - try writeOptionalString(allocator, buffer, d.password); - try writeOptionalString(allocator, buffer, d.connection_string); - try writeOptionalString(allocator, buffer, d.sid); -} - -fn serializeWifiData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), w: item.WifiData) !void { - const writer = buffer.writer(allocator); - - try writeString(allocator, buffer, w.ssid); - try writeOptionalString(allocator, buffer, w.password); - try writer.writeByte(@intFromEnum(w.security)); - try writer.writeByte(if (w.hidden) 1 else 0); -} - -fn serializeLicenseData(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), l: item.LicenseData) !void { - try writeOptionalString(allocator, buffer, l.license_key); - try writeOptionalString(allocator, buffer, l.product_name); - try writeOptionalString(allocator, buffer, l.version); - try writeOptionalString(allocator, buffer, l.publisher); - try writeOptionalString(allocator, buffer, l.email); - try writeOptionalI64(allocator, buffer, l.purchase_date); - try writeOptionalI64(allocator, buffer, l.expiration_date); -} - -fn writeString(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), s: []const u8) !void { - const writer = buffer.writer(allocator); - try writer.writeInt(u32, @intCast(s.len), .little); - try buffer.appendSlice(allocator, s); -} - -fn writeOptionalString(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), s: ?[]const u8) !void { - const writer = buffer.writer(allocator); - if (s) |str| { - try writer.writeByte(1); - try writeString(allocator, buffer, str); - } else { - try writer.writeByte(0); - } -} - -fn writeOptionalI64(allocator: std.mem.Allocator, buffer: *std.ArrayList(u8), v: ?i64) !void { - const writer = buffer.writer(allocator); - if (v) |val| { - try writer.writeByte(1); - try writer.writeInt(i64, val, .little); - } else { - try writer.writeByte(0); - } -} - -test "serialize empty items" { - const allocator = std.testing.allocator; - var items: [0]item.Item = .{}; - - const serialized = try serializeItems(allocator, &items); - defer allocator.free(serialized); - - try std.testing.expectEqual(@as(usize, 4), serialized.len); -} From e593664f354a7485c19e6c3f29b9e2c4d2a3e602 Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:26:02 +0100 Subject: [PATCH 12/19] feat(core): update Vault and CLI commands to use Item-based data model - Vault now stores Items instead of Entries - Renamed addEntry/getEntry/deleteEntry to addItem/getItem/deleteItem - Updated error types: EntryNotFound -> ItemNotFound, EntryAlreadyExists -> ItemAlreadyExists - CLI commands updated to work with new 9-type Item system - TOTP is now embedded in LoginData.totp instead of separate entry type - Passkeys are now embedded in LoginData.passkeys instead of separate entry type - List command shows all 9 item types with appropriate info --- src/cli/commands/add.zig | 42 +++---- src/cli/commands/delete.zig | 14 +-- src/cli/commands/get.zig | 63 +++++++--- src/cli/commands/list.zig | 40 +++--- src/cli/commands/passkey.zig | 123 ++++++++++--------- src/cli/commands/totp.zig | 229 +++++++++++++---------------------- src/core/vault.zig | 94 +++++++------- 7 files changed, 296 insertions(+), 309 deletions(-) diff --git a/src/cli/commands/add.zig b/src/cli/commands/add.zig index 7bdfb0d..674f6d5 100644 --- a/src/cli/commands/add.zig +++ b/src/cli/commands/add.zig @@ -1,7 +1,7 @@ const std = @import("std"); const core = @import("../../core/vault.zig"); const config = @import("../../core/config.zig"); -const entry = @import("../../core/entry.zig"); +const item = @import("../../core/item.zig"); const terminal = @import("../terminal.zig"); const memory = @import("../../memory/secure_allocator.zig"); const vault_helpers = @import("../vault_helpers.zig"); @@ -10,22 +10,22 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8, _: config.Con var ctx = vault_helpers.openAndUnlock(allocator) catch return; defer ctx.deinit(); - const entry_name = if (args.len > 0) + const item_name = if (args.len > 0) args[0] else blk: { - const name = terminal.readLine(allocator, "Entry name: ") catch { - std.debug.print("Error: Could not read entry name\n", .{}); + const name = terminal.readLine(allocator, "Item name: ") catch { + std.debug.print("Error: Could not read item name\n", .{}); return; }; if (name.len == 0) { allocator.free(name); - std.debug.print("Error: Entry name cannot be empty\n", .{}); + std.debug.print("Error: Item name cannot be empty\n", .{}); return; } break :blk name; }; const should_free_name = args.len == 0; - defer if (should_free_name) allocator.free(entry_name); + defer if (should_free_name) allocator.free(item_name); const username = terminal.readLine(allocator, "Username (optional): ") catch { std.debug.print("Error: Could not read username\n", .{}); @@ -34,16 +34,16 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8, _: config.Con defer allocator.free(username); const username_opt: ?[]const u8 = if (username.len > 0) username else null; - const entry_password = terminal.readPassword(allocator, "Password: ") catch { + const item_password = terminal.readPassword(allocator, "Password: ") catch { std.debug.print("Error: Could not read password\n", .{}); return; }; defer { - memory.secureZero(entry_password); - allocator.free(entry_password); + memory.secureZero(item_password); + allocator.free(item_password); } - if (entry_password.len == 0) { + if (item_password.len == 0) { std.debug.print("Error: Password cannot be empty\n", .{}); return; } @@ -55,26 +55,26 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8, _: config.Con defer allocator.free(url); const url_opt: ?[]const u8 = if (url.len > 0) url else null; - const new_entry = entry.createPasswordEntry( + const new_item = item.createLoginItem( allocator, - entry_name, + item_name, username_opt, - entry_password, + item_password, url_opt, ) catch { - std.debug.print("Error: Could not create entry\n", .{}); + std.debug.print("Error: Could not create item\n", .{}); return; }; - ctx.vault.addEntry(new_entry) catch |err| { - var mutable_entry = new_entry; - mutable_entry.deinit(allocator); + ctx.vault.addItem(new_item) catch |err| { + var mutable_item = new_item; + mutable_item.deinit(allocator); switch (err) { - core.VaultError.EntryAlreadyExists => { - std.debug.print("Error: Entry '{s}' already exists\n", .{entry_name}); + core.VaultError.ItemAlreadyExists => { + std.debug.print("Error: Item '{s}' already exists\n", .{item_name}); }, else => { - std.debug.print("Error: Could not add entry\n", .{}); + std.debug.print("Error: Could not add item\n", .{}); }, } return; @@ -85,5 +85,5 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8, _: config.Con return; }; - std.debug.print("Entry '{s}' added successfully.\n", .{entry_name}); + std.debug.print("Item '{s}' added successfully.\n", .{item_name}); } diff --git a/src/cli/commands/delete.zig b/src/cli/commands/delete.zig index 51a23f6..46a5f69 100644 --- a/src/cli/commands/delete.zig +++ b/src/cli/commands/delete.zig @@ -9,24 +9,24 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8, _: config.Con return; } - const entry_name = args[0]; + const item_name = args[0]; var ctx = vault_helpers.openAndUnlock(allocator) catch return; defer ctx.deinit(); - std.debug.print("Delete entry '{s}'? [y/N]: ", .{entry_name}); + std.debug.print("Delete item '{s}'? [y/N]: ", .{item_name}); if (!confirm()) { std.debug.print("Cancelled.\n", .{}); return; } - ctx.vault.deleteEntry(entry_name) catch |err| { + ctx.vault.deleteItem(item_name) catch |err| { switch (err) { - core.VaultError.EntryNotFound => { - std.debug.print("Error: Entry '{s}' not found\n", .{entry_name}); + core.VaultError.ItemNotFound => { + std.debug.print("Error: Item '{s}' not found\n", .{item_name}); }, else => { - std.debug.print("Error: Could not delete entry\n", .{}); + std.debug.print("Error: Could not delete item\n", .{}); }, } return; @@ -37,7 +37,7 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8, _: config.Con return; }; - std.debug.print("Entry '{s}' deleted.\n", .{entry_name}); + std.debug.print("Item '{s}' deleted.\n", .{item_name}); } fn confirm() bool { diff --git a/src/cli/commands/get.zig b/src/cli/commands/get.zig index f29f7fa..2adf23f 100644 --- a/src/cli/commands/get.zig +++ b/src/cli/commands/get.zig @@ -9,48 +9,75 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8, cfg: config.C return; } - const entry_name = args[0]; + const item_name = args[0]; var ctx = vault_helpers.openAndUnlock(allocator) catch return; defer ctx.deinit(); - const found_entry = ctx.vault.getEntry(entry_name); - if (found_entry == null) { - std.debug.print("Error: Entry '{s}' not found\n", .{entry_name}); + const found_item = ctx.vault.getItem(item_name); + if (found_item == null) { + std.debug.print("Error: Item '{s}' not found\n", .{item_name}); return; } - const e = found_entry.?; - switch (e.data) { - .password => |pwd| { + const i = found_item.?; + switch (i.data) { + .login => |login| { + const password = login.password orelse { + std.debug.print("Item '{s}' has no password set.\n", .{item_name}); + return; + }; + if (!cfg.clipboard_enabled) { - std.debug.print("Clipboard disabled in config. Password for '{s}':\n{s}\n", .{ entry_name, pwd.password }); + std.debug.print("Clipboard disabled in config. Password for '{s}':\n{s}\n", .{ item_name, password }); return; } - clipboard.copyWithTimeout(allocator, pwd.password, cfg.clipboard_timeout) catch { - std.debug.print("Error: Could not copy to clipboard. Password:\n{s}\n", .{pwd.password}); + clipboard.copyWithTimeout(allocator, password, cfg.clipboard_timeout) catch { + std.debug.print("Error: Could not copy to clipboard. Password:\n{s}\n", .{password}); return; }; - std.debug.print("Password for '{s}' copied to clipboard.", .{entry_name}); + std.debug.print("Password for '{s}' copied to clipboard.", .{item_name}); if (cfg.clipboard_timeout > 0) { std.debug.print(" Clearing in {d}s.", .{cfg.clipboard_timeout}); } std.debug.print("\n", .{}); - if (pwd.username) |u| { + if (login.username) |u| { std.debug.print("Username: {s}\n", .{u}); } - if (pwd.url) |url| { - std.debug.print("URL: {s}\n", .{url}); + if (login.uris.len > 0) { + std.debug.print("URL: {s}\n", .{login.uris[0].uri}); + } + }, + .card => { + std.debug.print("Item '{s}' is a card. Use 'zault card {s}' instead.\n", .{ item_name, item_name }); + }, + .secure_note => { + if (i.notes) |notes| { + std.debug.print("Note:\n{s}\n", .{notes}); + } else { + std.debug.print("Item '{s}' has no notes.\n", .{item_name}); } }, - .totp => { - std.debug.print("Entry '{s}' is a TOTP entry. Use 'zault totp {s}' instead.\n", .{ entry_name, entry_name }); + .identity => { + std.debug.print("Item '{s}' is an identity.\n", .{item_name}); + }, + .ssh_key => { + std.debug.print("Item '{s}' is an SSH key.\n", .{item_name}); + }, + .api_credential => { + std.debug.print("Item '{s}' is an API credential.\n", .{item_name}); + }, + .database => { + std.debug.print("Item '{s}' is a database credential.\n", .{item_name}); + }, + .wifi => { + std.debug.print("Item '{s}' is a WiFi credential.\n", .{item_name}); }, - .passkey => { - std.debug.print("Entry '{s}' is a passkey entry.\n", .{entry_name}); + .license => { + std.debug.print("Item '{s}' is a software license.\n", .{item_name}); }, } } diff --git a/src/cli/commands/list.zig b/src/cli/commands/list.zig index 8d7f5c6..89f295d 100644 --- a/src/cli/commands/list.zig +++ b/src/cli/commands/list.zig @@ -6,28 +6,40 @@ pub fn run(allocator: std.mem.Allocator, _: []const []const u8, _: config.Config var ctx = vault_helpers.openAndUnlock(allocator) catch return; defer ctx.deinit(); - if (ctx.vault.entries.items.len == 0) { - std.debug.print("Vault is empty. Use 'zault add ' to add entries.\n", .{}); + if (ctx.vault.items.items.len == 0) { + std.debug.print("Vault is empty. Use 'zault add ' to add items.\n", .{}); return; } - std.debug.print("Entries ({d}):\n", .{ctx.vault.entries.items.len}); - std.debug.print("{s:<30} {s:<12} {s}\n", .{ "NAME", "TYPE", "USERNAME/ISSUER" }); + std.debug.print("Items ({d}):\n", .{ctx.vault.items.items.len}); + std.debug.print("{s:<30} {s:<12} {s}\n", .{ "NAME", "TYPE", "USERNAME/INFO" }); std.debug.print("{s}\n", .{"-" ** 60}); - for (ctx.vault.entries.items) |e| { - const type_str = switch (e.entry_type) { - .password => "password", - .totp => "totp", - .passkey => "passkey", + for (ctx.vault.items.items) |i| { + const type_str = switch (i.item_type) { + .login => "login", + .secure_note => "note", + .card => "card", + .identity => "identity", + .ssh_key => "ssh_key", + .api_credential => "api", + .database => "database", + .wifi => "wifi", + .license => "license", }; - const extra = switch (e.data) { - .password => |p| p.username orelse "", - .totp => |t| t.issuer orelse "", - .passkey => |pk| pk.user_name orelse "", + const extra: []const u8 = switch (i.data) { + .login => |l| l.username orelse "", + .card => |c| c.cardholder_name orelse "", + .identity => |id| id.first_name orelse "", + .ssh_key => |s| @tagName(s.key_type), + .api_credential => |a| a.endpoint orelse "", + .database => |d| d.database orelse "", + .wifi => |w| w.ssid, + .license => |lic| lic.product_name orelse "", + .secure_note => "", }; - std.debug.print("{s:<30} {s:<12} {s}\n", .{ e.name, type_str, extra }); + std.debug.print("{s:<30} {s:<12} {s}\n", .{ i.name, type_str, extra }); } } diff --git a/src/cli/commands/passkey.zig b/src/cli/commands/passkey.zig index e70f7ef..05e7bf3 100644 --- a/src/cli/commands/passkey.zig +++ b/src/cli/commands/passkey.zig @@ -1,6 +1,7 @@ const std = @import("std"); const core = @import("../../core/vault.zig"); const config = @import("../../core/config.zig"); +const item = @import("../../core/item.zig"); const vault_helpers = @import("../vault_helpers.zig"); pub fn run(allocator: std.mem.Allocator, args: []const []const u8, _: config.Config) !void { @@ -38,106 +39,116 @@ fn runList(allocator: std.mem.Allocator) !void { defer ctx.deinit(); var count: usize = 0; - for (ctx.vault.entries.items) |e| { - if (e.entry_type == .passkey) { - count += 1; + for (ctx.vault.items.items) |i| { + if (i.item_type == .login and i.data.login.passkeys.len > 0) { + count += i.data.login.passkeys.len; } } if (count == 0) { - std.debug.print("No passkey entries found.\n", .{}); + std.debug.print("No passkeys found.\n", .{}); return; } - std.debug.print("Passkey Entries ({d}):\n", .{count}); - std.debug.print("{s:<25} {s:<30} {s}\n", .{ "NAME", "RP ID", "USER" }); + std.debug.print("Passkeys ({d}):\n", .{count}); + std.debug.print("{s:<25} {s:<30} {s}\n", .{ "ITEM", "RP ID", "USER" }); std.debug.print("{s}\n", .{"-" ** 70}); - for (ctx.vault.entries.items) |e| { - if (e.entry_type == .passkey) { - const pk = e.data.passkey; - const user = pk.user_name orelse ""; - std.debug.print("{s:<25} {s:<30} {s}\n", .{ e.name, pk.rp_id, user }); + for (ctx.vault.items.items) |i| { + if (i.item_type == .login) { + for (i.data.login.passkeys) |pk| { + const user = pk.user_name orelse ""; + std.debug.print("{s:<25} {s:<30} {s}\n", .{ i.name, pk.rp_id, user }); + } } } } -fn runShow(allocator: std.mem.Allocator, entry_name: []const u8) !void { +fn runShow(allocator: std.mem.Allocator, item_name: []const u8) !void { var ctx = vault_helpers.openAndUnlock(allocator) catch return; defer ctx.deinit(); - const found_entry = ctx.vault.getEntry(entry_name); - if (found_entry == null) { - std.debug.print("Error: Entry '{s}' not found\n", .{entry_name}); + const found_item = ctx.vault.getItem(item_name); + if (found_item == null) { + std.debug.print("Error: Item '{s}' not found\n", .{item_name}); return; } - const e = found_entry.?; - if (e.entry_type != .passkey) { - std.debug.print("Error: Entry '{s}' is not a passkey\n", .{entry_name}); + const i = found_item.?; + if (i.item_type != .login) { + std.debug.print("Error: Item '{s}' is not a login item\n", .{item_name}); return; } - const pk = e.data.passkey; - const algo_str = switch (pk.algorithm) { - .es256 => "ES256 (P-256)", - .ed25519 => "Ed25519", - .rs256 => "RS256", - }; + if (i.data.login.passkeys.len == 0) { + std.debug.print("Item '{s}' has no passkeys\n", .{item_name}); + return; + } - std.debug.print("\nPasskey: {s}\n", .{e.name}); + std.debug.print("\nPasskeys for '{s}':\n", .{i.name}); std.debug.print("{s}\n", .{"-" ** 40}); - std.debug.print("Relying Party ID: {s}\n", .{pk.rp_id}); - if (pk.rp_name) |rp_name| { - std.debug.print("Relying Party Name: {s}\n", .{rp_name}); - } - if (pk.user_name) |user_name| { - std.debug.print("User Name: {s}\n", .{user_name}); + + for (i.data.login.passkeys, 0..) |pk, idx| { + const algo_str = switch (pk.algorithm) { + .es256 => "ES256 (P-256)", + .ed25519 => "Ed25519", + .rs256 => "RS256", + }; + + std.debug.print("\nPasskey #{d}:\n", .{idx + 1}); + std.debug.print(" Relying Party ID: {s}\n", .{pk.rp_id}); + if (pk.rp_name) |rp_name| { + std.debug.print(" Relying Party Name: {s}\n", .{rp_name}); + } + if (pk.user_name) |user_name| { + std.debug.print(" User Name: {s}\n", .{user_name}); + } + std.debug.print(" Algorithm: {s}\n", .{algo_str}); + std.debug.print(" Sign Count: {d}\n", .{pk.counter}); + std.debug.print(" Credential ID: {s}...\n", .{pk.credential_id[0..@min(16, pk.credential_id.len)]}); } - std.debug.print("Algorithm: {s}\n", .{algo_str}); - std.debug.print("Sign Count: {d}\n", .{pk.counter}); - std.debug.print("Credential ID: {s}...\n", .{pk.credential_id[0..@min(16, pk.credential_id.len)]}); } -fn runDelete(allocator: std.mem.Allocator, entry_name: []const u8) !void { +fn runDelete(allocator: std.mem.Allocator, item_name: []const u8) !void { var ctx = vault_helpers.openAndUnlock(allocator) catch return; defer ctx.deinit(); - const found_entry = ctx.vault.getEntry(entry_name); - if (found_entry == null) { - std.debug.print("Error: Entry '{s}' not found\n", .{entry_name}); + const found_item = ctx.vault.getItem(item_name); + if (found_item == null) { + std.debug.print("Error: Item '{s}' not found\n", .{item_name}); return; } - if (found_entry.?.entry_type != .passkey) { - std.debug.print("Error: Entry '{s}' is not a passkey\n", .{entry_name}); + const i = found_item.?; + if (i.item_type != .login) { + std.debug.print("Error: Item '{s}' is not a login item\n", .{item_name}); return; } - std.debug.print("Delete passkey '{s}'? [y/N]: ", .{entry_name}); + if (i.data.login.passkeys.len == 0) { + std.debug.print("Item '{s}' has no passkeys\n", .{item_name}); + return; + } + + std.debug.print("Delete all passkeys from '{s}'? [y/N]: ", .{item_name}); if (!confirm()) { std.debug.print("Cancelled.\n", .{}); return; } - ctx.vault.deleteEntry(entry_name) catch |err| { - switch (err) { - core.VaultError.EntryNotFound => { - std.debug.print("Error: Entry '{s}' not found\n", .{entry_name}); - }, - else => { - std.debug.print("Error: Could not delete entry\n", .{}); - }, - } - return; - }; + for (i.data.login.passkeys) |*pk| { + pk.deinit(allocator); + } + allocator.free(i.data.login.passkeys); + i.data.login.passkeys = &.{}; + i.modified_at = std.time.timestamp(); ctx.vault.save() catch { std.debug.print("Error: Could not save vault\n", .{}); return; }; - std.debug.print("Passkey '{s}' deleted.\n", .{entry_name}); + std.debug.print("Passkeys deleted from '{s}'.\n", .{item_name}); } fn confirm() bool { @@ -155,9 +166,9 @@ fn showHelp() void { \\Usage: zault passkey [options] \\ \\Subcommands: - \\ list List all passkey entries - \\ show Show passkey details - \\ delete Delete a passkey entry + \\ list List all passkeys + \\ show Show passkey details for an item + \\ delete Delete passkeys from an item \\ \\Note: Passkeys are typically created through browser-based \\WebAuthn flows and stored in the vault by external tools. diff --git a/src/cli/commands/totp.zig b/src/cli/commands/totp.zig index e70b91d..59f0308 100644 --- a/src/cli/commands/totp.zig +++ b/src/cli/commands/totp.zig @@ -1,7 +1,7 @@ const std = @import("std"); const core = @import("../../core/vault.zig"); const config = @import("../../core/config.zig"); -const entry = @import("../../core/entry.zig"); +const item = @import("../../core/item.zig"); const terminal = @import("../terminal.zig"); const memory = @import("../../memory/secure_allocator.zig"); const clipboard = @import("../../services/clipboard.zig"); @@ -37,22 +37,22 @@ fn runAdd(allocator: std.mem.Allocator, args: []const []const u8) !void { var ctx = vault_helpers.openAndUnlock(allocator) catch return; defer ctx.deinit(); - const entry_name = if (args.len > 0) + const item_name = if (args.len > 0) args[0] else blk: { - const name = terminal.readLine(allocator, "Entry name: ") catch { - std.debug.print("Error: Could not read entry name\n", .{}); + const name = terminal.readLine(allocator, "Item name: ") catch { + std.debug.print("Error: Could not read item name\n", .{}); return; }; if (name.len == 0) { allocator.free(name); - std.debug.print("Error: Entry name cannot be empty\n", .{}); + std.debug.print("Error: Item name cannot be empty\n", .{}); return; } break :blk name; }; const should_free_name = args.len == 0; - defer if (should_free_name) allocator.free(entry_name); + defer if (should_free_name) allocator.free(item_name); const secret = terminal.readPassword(allocator, "TOTP Secret (base32): ") catch { std.debug.print("Error: Could not read secret\n", .{}); @@ -79,160 +79,101 @@ fn runAdd(allocator: std.mem.Allocator, args: []const []const u8) !void { return; }; - const existing = ctx.vault.getEntry(entry_name); - if (existing) |e| { - if (e.entry_type == .password) { - if (e.data.password.totp_secret) |old_secret| { - @memset(@constCast(old_secret), 0); - allocator.free(old_secret); - } - e.data.password.totp_secret = secret; - e.data.password.totp_algorithm = .sha1; - e.data.password.totp_digits = 6; - e.data.password.totp_period = 30; - e.modified_at = std.time.timestamp(); - - ctx.vault.save() catch { - std.debug.print("Error: Could not save vault\n", .{}); - return; - }; - - std.debug.print("TOTP added to existing entry '{s}'.\n", .{entry_name}); - return; - } else { + const existing = ctx.vault.getItem(item_name); + if (existing) |i| { + if (i.item_type != .login) { memory.secureZero(secret); allocator.free(secret); - std.debug.print("Error: Entry '{s}' exists but is not a password entry\n", .{entry_name}); + std.debug.print("Error: Item '{s}' is not a login item\n", .{item_name}); return; } - } - - const issuer = terminal.readLine(allocator, "Issuer (optional): ") catch { - memory.secureZero(secret); - allocator.free(secret); - std.debug.print("Error: Could not read issuer\n", .{}); - return; - }; - defer allocator.free(issuer); - const issuer_opt: ?[]const u8 = if (issuer.len > 0) issuer else null; - - const new_entry = entry.createTotpEntry( - allocator, - entry_name, - secret, - issuer_opt, - .sha1, - 6, - 30, - ) catch { - memory.secureZero(secret); - allocator.free(secret); - std.debug.print("Error: Could not create entry\n", .{}); - return; - }; - ctx.vault.addEntry(new_entry) catch |err| { - var mutable_entry = new_entry; - mutable_entry.deinit(allocator); - switch (err) { - core.VaultError.EntryAlreadyExists => { - std.debug.print("Error: Entry '{s}' already exists\n", .{entry_name}); - }, - else => { - std.debug.print("Error: Could not add entry\n", .{}); - }, + if (i.data.login.totp) |*old_totp| { + old_totp.deinit(allocator); } - return; - }; - ctx.vault.save() catch { - std.debug.print("Error: Could not save vault\n", .{}); + i.data.login.totp = .{ + .secret = secret, + .algorithm = .sha1, + .digits = 6, + .period = 30, + }; + i.modified_at = std.time.timestamp(); + + ctx.vault.save() catch { + std.debug.print("Error: Could not save vault\n", .{}); + return; + }; + + std.debug.print("TOTP added to existing item '{s}'.\n", .{item_name}); return; - }; + } - std.debug.print("TOTP entry '{s}' added successfully.\n", .{entry_name}); + memory.secureZero(secret); + allocator.free(secret); + std.debug.print("Error: Item '{s}' not found. Create it first with 'zault add {s}'.\n", .{ item_name, item_name }); } -fn runDelete(allocator: std.mem.Allocator, entry_name: []const u8) !void { +fn runDelete(allocator: std.mem.Allocator, item_name: []const u8) !void { var ctx = vault_helpers.openAndUnlock(allocator) catch return; defer ctx.deinit(); - const found_entry = ctx.vault.getEntry(entry_name); - if (found_entry == null) { - std.debug.print("Error: Entry '{s}' not found\n", .{entry_name}); + const found_item = ctx.vault.getItem(item_name); + if (found_item == null) { + std.debug.print("Error: Item '{s}' not found\n", .{item_name}); return; } - const e = found_entry.?; - switch (e.data) { - .totp => { - ctx.vault.deleteEntry(entry_name) catch { - std.debug.print("Error: Could not delete entry\n", .{}); - return; - }; - ctx.vault.save() catch { - std.debug.print("Error: Could not save vault\n", .{}); - return; - }; - std.debug.print("TOTP entry '{s}' deleted.\n", .{entry_name}); - }, - .password => |*p| { - if (p.totp_secret == null) { - std.debug.print("Entry '{s}' has no TOTP configured.\n", .{entry_name}); - return; - } - @memset(@constCast(p.totp_secret.?), 0); - allocator.free(p.totp_secret.?); - p.totp_secret = null; - p.totp_algorithm = .sha1; - p.totp_digits = 6; - p.totp_period = 30; - e.modified_at = std.time.timestamp(); - - ctx.vault.save() catch { - std.debug.print("Error: Could not save vault\n", .{}); - return; - }; - std.debug.print("TOTP removed from '{s}'.\n", .{entry_name}); - }, - .passkey => { - std.debug.print("Entry '{s}' is a passkey, not a TOTP entry.\n", .{entry_name}); - }, + const i = found_item.?; + if (i.item_type != .login) { + std.debug.print("Error: Item '{s}' is not a login item\n", .{item_name}); + return; } + + if (i.data.login.totp == null) { + std.debug.print("Item '{s}' has no TOTP configured.\n", .{item_name}); + return; + } + + i.data.login.totp.?.deinit(allocator); + i.data.login.totp = null; + i.modified_at = std.time.timestamp(); + + ctx.vault.save() catch { + std.debug.print("Error: Could not save vault\n", .{}); + return; + }; + std.debug.print("TOTP removed from '{s}'.\n", .{item_name}); } -fn runGet(allocator: std.mem.Allocator, entry_name: []const u8, cfg: config.Config) !void { +fn runGet(allocator: std.mem.Allocator, item_name: []const u8, cfg: config.Config) !void { var ctx = vault_helpers.openAndUnlock(allocator) catch return; defer ctx.deinit(); - const found_entry = ctx.vault.getEntry(entry_name); - if (found_entry == null) { - std.debug.print("Error: Entry '{s}' not found\n", .{entry_name}); + const found_item = ctx.vault.getItem(item_name); + if (found_item == null) { + std.debug.print("Error: Item '{s}' not found\n", .{item_name}); return; } - const e = found_entry.?; - switch (e.data) { - .totp => |t| { - try generateAndShowCode(allocator, t.secret, t.algorithm, t.digits, t.period, cfg); - }, - .password => |p| { - if (p.totp_secret) |secret| { - try generateAndShowCode(allocator, secret, p.totp_algorithm, p.totp_digits, p.totp_period, cfg); - } else { - std.debug.print("Entry '{s}' has no TOTP configured. Use 'zault totp add {s}' to add one.\n", .{ entry_name, entry_name }); - } - }, - .passkey => { - std.debug.print("Entry '{s}' is a passkey entry.\n", .{entry_name}); - }, + const i = found_item.?; + if (i.item_type != .login) { + std.debug.print("Error: Item '{s}' is not a login item\n", .{item_name}); + return; } + + const t = i.data.login.totp orelse { + std.debug.print("Item '{s}' has no TOTP configured. Use 'zault totp add {s}' to add one.\n", .{ item_name, item_name }); + return; + }; + + try generateAndShowCode(allocator, t.secret, t.algorithm, t.digits, t.period, cfg); } fn generateAndShowCode( allocator: std.mem.Allocator, secret: []const u8, - algorithm: entry.TotpAlgorithm, + algorithm: item.TotpAlgorithm, digits: u8, period: u32, cfg: config.Config, @@ -283,29 +224,25 @@ fn runList(allocator: std.mem.Allocator) !void { defer ctx.deinit(); var count: usize = 0; - for (ctx.vault.entries.items) |e| { - if (e.entry_type == .totp) { - count += 1; - } else if (e.entry_type == .password and e.data.password.hasTotp()) { + for (ctx.vault.items.items) |i| { + if (i.item_type == .login and i.data.login.totp != null) { count += 1; } } if (count == 0) { - std.debug.print("No TOTP entries. Use 'zault totp add ' to add one.\n", .{}); + std.debug.print("No items with TOTP. Use 'zault totp add ' to add one.\n", .{}); return; } - std.debug.print("TOTP Entries ({d}):\n", .{count}); - std.debug.print("{s:<30} {s:<12} {s}\n", .{ "NAME", "TYPE", "ISSUER" }); - std.debug.print("{s}\n", .{"-" ** 55}); + std.debug.print("Items with TOTP ({d}):\n", .{count}); + std.debug.print("{s:<30} {s}\n", .{ "NAME", "USERNAME" }); + std.debug.print("{s}\n", .{"-" ** 45}); - for (ctx.vault.entries.items) |e| { - if (e.entry_type == .totp) { - const issuer = e.data.totp.issuer orelse ""; - std.debug.print("{s:<30} {s:<12} {s}\n", .{ e.name, "standalone", issuer }); - } else if (e.entry_type == .password and e.data.password.hasTotp()) { - std.debug.print("{s:<30} {s:<12} {s}\n", .{ e.name, "password+", "" }); + for (ctx.vault.items.items) |i| { + if (i.item_type == .login and i.data.login.totp != null) { + const username = i.data.login.username orelse ""; + std.debug.print("{s:<30} {s}\n", .{ i.name, username }); } } } @@ -315,10 +252,10 @@ fn showHelp() void { \\Usage: zault totp [options] \\ \\Subcommands: - \\ add Add TOTP to entry (or create standalone) - \\ delete Remove TOTP from entry - \\ list List all entries with TOTP - \\ Get current TOTP code for entry + \\ add Add TOTP to a login item + \\ delete Remove TOTP from a login item + \\ list List all items with TOTP + \\ Get current TOTP code for item \\ \\Examples: \\ zault totp add github.com diff --git a/src/core/vault.zig b/src/core/vault.zig index fc201eb..4955838 100644 --- a/src/core/vault.zig +++ b/src/core/vault.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const entry = @import("entry.zig"); +const item = @import("item.zig"); const serializer = @import("serializer.zig"); const argon2 = @import("../crypto/argon2.zig"); const xchacha = @import("../crypto/xchacha.zig"); @@ -10,8 +10,8 @@ pub const VaultError = error{ VaultLocked, VaultCorrupted, InvalidMasterPassword, - EntryNotFound, - EntryAlreadyExists, + ItemNotFound, + ItemAlreadyExists, IoError, CryptoError, InvalidMagic, @@ -129,7 +129,7 @@ pub const VaultHeader = struct { pub const Vault = struct { allocator: std.mem.Allocator, header: VaultHeader, - entries: std.ArrayList(entry.Entry), + items: std.ArrayList(item.Item), is_locked: bool, path: []const u8, derived_key: ?[argon2.key_length]u8, @@ -143,7 +143,7 @@ pub const Vault = struct { .salt = undefined, .nonce = undefined, }, - .entries = .empty, + .items = .empty, .is_locked = true, .path = path, .derived_key = null, @@ -151,10 +151,10 @@ pub const Vault = struct { } pub fn deinit(self: *Self) void { - for (self.entries.items) |*e| { - e.deinit(self.allocator); + for (self.items.items) |*i| { + i.deinit(self.allocator); } - self.entries.deinit(self.allocator); + self.items.deinit(self.allocator); if (self.derived_key) |*key| { memory.secureZero(key); @@ -199,7 +199,7 @@ pub const Vault = struct { return Self{ .allocator = allocator, .header = header, - .entries = .empty, + .items = .empty, .is_locked = true, .path = path, .derived_key = null, @@ -221,14 +221,14 @@ pub const Vault = struct { ); errdefer self.clearKey(); - try self.decryptAndLoadEntries(); + try self.decryptAndLoadItems(); } pub fn unlockWithKey(self: *Self, key: [argon2.key_length]u8) !void { self.derived_key = key; errdefer self.clearKey(); - try self.decryptAndLoadEntries(); + try self.decryptAndLoadItems(); } fn clearKey(self: *Self) void { @@ -238,7 +238,7 @@ pub const Vault = struct { } } - fn decryptAndLoadEntries(self: *Self) !void { + fn decryptAndLoadItems(self: *Self) !void { const file = std.fs.cwd().openFile(self.path, .{}) catch return VaultError.VaultNotFound; defer file.close(); @@ -278,28 +278,28 @@ pub const Vault = struct { &self.header.nonce, ) catch return VaultError.InvalidMasterPassword; - const entries = serializer.deserializeEntries(self.allocator, plaintext) catch return VaultError.VaultCorrupted; + const items_slice = serializer.deserializeItems(self.allocator, plaintext) catch return VaultError.VaultCorrupted; - for (entries) |e| { - self.entries.append(self.allocator, e) catch { - for (entries) |*ent| { - var mut_ent = ent.*; - mut_ent.deinit(self.allocator); + for (items_slice) |i| { + self.items.append(self.allocator, i) catch { + for (items_slice) |*it| { + var mut_it = it.*; + mut_it.deinit(self.allocator); } - self.allocator.free(entries); + self.allocator.free(items_slice); return VaultError.IoError; }; } - self.allocator.free(entries); + self.allocator.free(items_slice); self.is_locked = false; } pub fn lock(self: *Self) void { - for (self.entries.items) |*e| { - e.deinit(self.allocator); + for (self.items.items) |*i| { + i.deinit(self.allocator); } - self.entries.clearRetainingCapacity(); + self.items.clearRetainingCapacity(); self.clearKey(); self.is_locked = true; } @@ -313,7 +313,7 @@ pub const Vault = struct { std.crypto.random.bytes(&self.header.nonce); - const plaintext = serializer.serializeEntries(self.allocator, self.entries.items) catch return VaultError.IoError; + const plaintext = serializer.serializeItems(self.allocator, self.items.items) catch return VaultError.IoError; defer { memory.secureZero(plaintext); self.allocator.free(plaintext); @@ -340,48 +340,48 @@ pub const Vault = struct { file.writeAll(&tag) catch return VaultError.IoError; } - pub fn addEntry(self: *Self, new_entry: entry.Entry) !void { + pub fn addItem(self: *Self, new_item: item.Item) !void { if (self.is_locked) { return VaultError.VaultLocked; } - for (self.entries.items) |e| { - if (std.mem.eql(u8, e.name, new_entry.name)) { - return VaultError.EntryAlreadyExists; + for (self.items.items) |i| { + if (std.mem.eql(u8, i.name, new_item.name)) { + return VaultError.ItemAlreadyExists; } } - try self.entries.append(self.allocator, new_entry); + try self.items.append(self.allocator, new_item); } - pub fn getEntry(self: *Self, name: []const u8) ?*entry.Entry { + pub fn getItem(self: *Self, name: []const u8) ?*item.Item { if (self.is_locked) { return null; } - for (self.entries.items) |*e| { - if (std.mem.eql(u8, e.name, name)) { - return e; + for (self.items.items) |*i| { + if (std.mem.eql(u8, i.name, name)) { + return i; } } return null; } - pub fn deleteEntry(self: *Self, name: []const u8) !void { + pub fn deleteItem(self: *Self, name: []const u8) !void { if (self.is_locked) { return VaultError.VaultLocked; } - for (self.entries.items, 0..) |*e, i| { - if (std.mem.eql(u8, e.name, name)) { - e.deinit(self.allocator); - _ = self.entries.orderedRemove(i); + for (self.items.items, 0..) |*i, idx| { + if (std.mem.eql(u8, i.name, name)) { + i.deinit(self.allocator); + _ = self.items.orderedRemove(idx); return; } } - return VaultError.EntryNotFound; + return VaultError.ItemNotFound; } }; @@ -432,7 +432,7 @@ test "vault init" { defer vault.deinit(); try std.testing.expect(vault.is_locked); - try std.testing.expectEqual(@as(usize, 0), vault.entries.items.len); + try std.testing.expectEqual(@as(usize, 0), vault.items.items.len); } test "vault header serialize deserialize" { @@ -460,14 +460,14 @@ test "vault create save open unlock roundtrip" { var vault = try Vault.create(allocator, test_path, "testpassword123"); defer vault.deinit(); - const e = try entry.createPasswordEntry( + const i = try item.createLoginItem( allocator, "github.com", "testuser", "secretpassword", "https://github.com", ); - try vault.addEntry(e); + try vault.addItem(i); try vault.save(); } @@ -481,11 +481,11 @@ test "vault create save open unlock roundtrip" { try vault.unlock("testpassword123"); try std.testing.expect(!vault.is_locked); - try std.testing.expectEqual(@as(usize, 1), vault.entries.items.len); + try std.testing.expectEqual(@as(usize, 1), vault.items.items.len); - const retrieved = vault.getEntry("github.com"); + const retrieved = vault.getItem("github.com"); try std.testing.expect(retrieved != null); - try std.testing.expectEqualStrings("testuser", retrieved.?.data.password.username.?); + try std.testing.expectEqualStrings("testuser", retrieved.?.data.login.username.?); } } @@ -499,8 +499,8 @@ test "vault wrong password fails" { var vault = try Vault.create(allocator, test_path, "correctpassword"); defer vault.deinit(); - const e = try entry.createPasswordEntry(allocator, "test", null, "pass", null); - try vault.addEntry(e); + const i = try item.createLoginItem(allocator, "test", null, "pass", null); + try vault.addItem(i); try vault.save(); } From a80e4e01b8218fd2ced381ffee8e0dc28d1f8d48 Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:27:23 +0100 Subject: [PATCH 13/19] refactor(core): remove old Entry module - Delete entry.zig which had the old 3-type system (password, totp, passkey) - Update main.zig to remove entry import and add item/uuid/serializer to tests - New Item-based system with 9 types is now the only data model --- src/core/entry.zig | 209 --------------------------------------------- src/main.zig | 5 +- 2 files changed, 3 insertions(+), 211 deletions(-) delete mode 100644 src/core/entry.zig diff --git a/src/core/entry.zig b/src/core/entry.zig deleted file mode 100644 index ef4948e..0000000 --- a/src/core/entry.zig +++ /dev/null @@ -1,209 +0,0 @@ -const std = @import("std"); - -pub const EntryType = enum(u8) { - password = 1, - totp = 2, - passkey = 3, -}; - -pub const Entry = struct { - name: []const u8, - entry_type: EntryType, - created_at: i64, - modified_at: i64, - data: EntryData, - - const Self = @This(); - - pub fn deinit(self: *Self, allocator: std.mem.Allocator) void { - allocator.free(self.name); - - switch (self.data) { - .password => |*p| p.deinit(allocator), - .totp => |*t| t.deinit(allocator), - .passkey => |*pk| pk.deinit(allocator), - } - } -}; - -pub const EntryData = union(EntryType) { - password: PasswordEntry, - totp: TotpEntry, - passkey: PasskeyEntry, -}; - -pub const PasswordEntry = struct { - username: ?[]const u8, - password: []const u8, - url: ?[]const u8, - notes: ?[]const u8, - totp_secret: ?[]const u8 = null, - totp_algorithm: TotpAlgorithm = .sha1, - totp_digits: u8 = 6, - totp_period: u32 = 30, - - const Self = @This(); - - pub fn deinit(self: *Self, allocator: std.mem.Allocator) void { - if (self.username) |u| allocator.free(u); - @memset(@constCast(self.password), 0); - allocator.free(self.password); - if (self.url) |url| allocator.free(url); - if (self.notes) |n| allocator.free(n); - if (self.totp_secret) |secret| { - @memset(@constCast(secret), 0); - allocator.free(secret); - } - } - - pub fn hasTotp(self: *const Self) bool { - return self.totp_secret != null; - } -}; - -pub const TotpEntry = struct { - secret: []const u8, - algorithm: TotpAlgorithm = .sha1, - digits: u8 = 6, - period: u32 = 30, - issuer: ?[]const u8, - - const Self = @This(); - - pub fn deinit(self: *Self, allocator: std.mem.Allocator) void { - @memset(@constCast(self.secret), 0); - allocator.free(self.secret); - if (self.issuer) |i| allocator.free(i); - } -}; - -pub const TotpAlgorithm = enum(u8) { - sha1 = 1, - sha256 = 2, - sha512 = 3, -}; - -pub const PasskeyEntry = struct { - credential_id: []const u8, - private_key: []const u8, - public_key: []const u8, - algorithm: PasskeyAlgorithm, - rp_id: []const u8, - rp_name: ?[]const u8, - user_handle: []const u8, - user_name: ?[]const u8, - counter: u32, - - const Self = @This(); - - pub fn deinit(self: *Self, allocator: std.mem.Allocator) void { - allocator.free(self.credential_id); - @memset(@constCast(self.private_key), 0); - allocator.free(self.private_key); - allocator.free(self.public_key); - allocator.free(self.rp_id); - if (self.rp_name) |n| allocator.free(n); - allocator.free(self.user_handle); - if (self.user_name) |n| allocator.free(n); - } -}; - -pub const PasskeyAlgorithm = enum(i32) { - es256 = -7, - ed25519 = -8, - rs256 = -257, -}; - -pub fn createPasswordEntry( - allocator: std.mem.Allocator, - name: []const u8, - username: ?[]const u8, - password: []const u8, - url: ?[]const u8, -) !Entry { - const now = std.time.timestamp(); - - const name_copy = try allocator.dupe(u8, name); - errdefer allocator.free(name_copy); - - const username_copy = if (username) |u| try allocator.dupe(u8, u) else null; - errdefer if (username_copy) |u| allocator.free(u); - - const password_copy = try allocator.dupe(u8, password); - errdefer allocator.free(password_copy); - - const url_copy = if (url) |u| try allocator.dupe(u8, u) else null; - errdefer if (url_copy) |u| allocator.free(u); - - return Entry{ - .name = name_copy, - .entry_type = .password, - .created_at = now, - .modified_at = now, - .data = .{ - .password = .{ - .username = username_copy, - .password = password_copy, - .url = url_copy, - .notes = null, - }, - }, - }; -} - -pub fn createTotpEntry( - allocator: std.mem.Allocator, - name: []const u8, - secret: []const u8, - issuer: ?[]const u8, - algorithm: TotpAlgorithm, - digits: u8, - period: u32, -) !Entry { - const now = std.time.timestamp(); - - const name_copy = try allocator.dupe(u8, name); - errdefer allocator.free(name_copy); - - const secret_copy = try allocator.dupe(u8, secret); - errdefer allocator.free(secret_copy); - - const issuer_copy = if (issuer) |i| try allocator.dupe(u8, i) else null; - errdefer if (issuer_copy) |i| allocator.free(i); - - return Entry{ - .name = name_copy, - .entry_type = .totp, - .created_at = now, - .modified_at = now, - .data = .{ - .totp = .{ - .secret = secret_copy, - .algorithm = algorithm, - .digits = digits, - .period = period, - .issuer = issuer_copy, - }, - }, - }; -} - -test "create password entry" { - const allocator = std.testing.allocator; - - var e = try createPasswordEntry( - allocator, - "github.com", - "testuser", - "secretpassword", - "https://github.com", - ); - defer e.deinit(allocator); - - try std.testing.expectEqualStrings("github.com", e.name); - try std.testing.expectEqual(EntryType.password, e.entry_type); - - const pwd = e.data.password; - try std.testing.expectEqualStrings("testuser", pwd.username.?); - try std.testing.expectEqualStrings("secretpassword", pwd.password); -} diff --git a/src/main.zig b/src/main.zig index 5e3ef94..8898d32 100644 --- a/src/main.zig +++ b/src/main.zig @@ -6,7 +6,6 @@ const cli = @import("cli/root.zig"); const commands = @import("cli/commands/root.zig"); const core = @import("core/vault.zig"); const config = @import("core/config.zig"); -const entry = @import("core/entry.zig"); const memory = @import("memory/secure_allocator.zig"); const argon2 = @import("crypto/argon2.zig"); const xchacha = @import("crypto/xchacha.zig"); @@ -124,11 +123,13 @@ test { _ = cli; _ = core; _ = config; - _ = entry; _ = memory; _ = argon2; _ = xchacha; _ = totp; _ = generator; _ = clipboard; + _ = @import("core/item.zig"); + _ = @import("core/uuid.zig"); + _ = @import("core/serializer.zig"); } From 1f7b4a908773e3a4561dd7f771c46ca5044cdd58 Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:31:43 +0100 Subject: [PATCH 14/19] docs: update help text to use 'items' instead of 'entries' --- src/main.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.zig b/src/main.zig index 8898d32..b025276 100644 --- a/src/main.zig +++ b/src/main.zig @@ -83,8 +83,8 @@ fn showHelp() !void { \\ init Create a new vault \\ add Add a new credential \\ get Retrieve a credential (copies to clipboard) - \\ list List all entries - \\ delete Remove an entry + \\ list List all items + \\ delete Remove an item \\ generate Generate a secure password \\ totp TOTP operations (add, list, get) \\ passkey Passkey operations (list, delete) From 2de5d3ac265f982daac0b8ae4a5dc8e96f12a679 Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:42:28 +0100 Subject: [PATCH 15/19] fix(core): add memory safety to deserialization and bump vault version - Add proper errdefer cleanup in all deserialization functions - Secure zero sensitive data (passwords, keys, SSN, etc.) on errors - Fix inconsistent null handling for CardBrand enum (return error instead of silent null) - Bump vault version from 1 to 2 for new item-based format --- src/core/serializer.zig | 249 ++++++++++++++++++++++++++++++++++++---- src/core/vault.zig | 2 +- 2 files changed, 227 insertions(+), 24 deletions(-) diff --git a/src/core/serializer.zig b/src/core/serializer.zig index 2a16bff..1b39c63 100644 --- a/src/core/serializer.zig +++ b/src/core/serializer.zig @@ -280,20 +280,40 @@ fn deserializeItem(allocator: std.mem.Allocator, reader: anytype) !item.Item { const fields_count = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; var fields = try allocator.alloc(item.CustomField, fields_count); - errdefer allocator.free(fields); + var fields_initialized: usize = 0; + errdefer { + for (0..fields_initialized) |j| { + var f = fields[j]; + f.deinit(allocator); + } + allocator.free(fields); + } for (0..fields_count) |i| { fields[i] = try deserializeCustomField(allocator, reader); + fields_initialized = i + 1; } const history_count = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; var history = try allocator.alloc(item.PasswordHistoryEntry, history_count); - errdefer allocator.free(history); + var history_initialized: usize = 0; + errdefer { + for (0..history_initialized) |j| { + var h = history[j]; + h.deinit(allocator); + } + allocator.free(history); + } for (0..history_count) |i| { const pw = try readString(allocator, reader); + errdefer { + @memset(@constCast(pw), 0); + allocator.free(pw); + } const changed_at = reader.readInt(i64, .little) catch return DeserializeError.UnexpectedEndOfData; history[i] = .{ .password = pw, .changed_at = changed_at }; + history_initialized = i + 1; } const data = try deserializeItemData(allocator, reader, item_type); @@ -351,23 +371,38 @@ fn deserializeLoginData(allocator: std.mem.Allocator, reader: anytype) !item.Log errdefer if (username) |u| allocator.free(u); const password = try readOptionalString(allocator, reader); - errdefer if (password) |p| allocator.free(p); + errdefer if (password) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + }; const uri_count = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; var uris = try allocator.alloc(item.Uri, uri_count); - errdefer allocator.free(uris); + var uris_initialized: usize = 0; + errdefer { + for (0..uris_initialized) |j| { + allocator.free(uris[j].uri); + } + allocator.free(uris); + } for (0..uri_count) |i| { const uri_str = try readString(allocator, reader); + errdefer allocator.free(uri_str); const match_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; const match_type = std.meta.intToEnum(item.UriMatchType, match_byte) catch return DeserializeError.InvalidData; uris[i] = .{ .uri = uri_str, .match_type = match_type }; + uris_initialized = i + 1; } const has_totp = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; var totp: ?item.TotpData = null; if (has_totp == 1) { const secret = try readString(allocator, reader); + errdefer { + @memset(@constCast(secret), 0); + allocator.free(secret); + } const algo_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; const algorithm = std.meta.intToEnum(item.TotpAlgorithm, algo_byte) catch return DeserializeError.InvalidData; const digits = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; @@ -379,13 +414,25 @@ fn deserializeLoginData(allocator: std.mem.Allocator, reader: anytype) !item.Log .period = period, }; } + errdefer if (totp) |*t| { + var td = t.*; + td.deinit(allocator); + }; const pk_count = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; var passkeys = try allocator.alloc(item.PasskeyData, pk_count); - errdefer allocator.free(passkeys); + var passkeys_initialized: usize = 0; + errdefer { + for (0..passkeys_initialized) |j| { + var pk = passkeys[j]; + pk.deinit(allocator); + } + allocator.free(passkeys); + } for (0..pk_count) |i| { passkeys[i] = try deserializePasskeyData(allocator, reader); + passkeys_initialized = i + 1; } return .{ @@ -399,14 +446,32 @@ fn deserializeLoginData(allocator: std.mem.Allocator, reader: anytype) !item.Log fn deserializePasskeyData(allocator: std.mem.Allocator, reader: anytype) !item.PasskeyData { const credential_id = try readString(allocator, reader); + errdefer allocator.free(credential_id); + const private_key = try readString(allocator, reader); + errdefer { + @memset(@constCast(private_key), 0); + allocator.free(private_key); + } + const public_key = try readString(allocator, reader); + errdefer allocator.free(public_key); + const algo_int = reader.readInt(i32, .little) catch return DeserializeError.UnexpectedEndOfData; const algorithm = std.meta.intToEnum(item.PasskeyAlgorithm, algo_int) catch return DeserializeError.InvalidData; + const rp_id = try readString(allocator, reader); + errdefer allocator.free(rp_id); + const rp_name = try readOptionalString(allocator, reader); + errdefer if (rp_name) |n| allocator.free(n); + const user_handle = try readString(allocator, reader); + errdefer allocator.free(user_handle); + const user_name = try readOptionalString(allocator, reader); + errdefer if (user_name) |n| allocator.free(n); + const counter = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; return .{ @@ -424,15 +489,32 @@ fn deserializePasskeyData(allocator: std.mem.Allocator, reader: anytype) !item.P fn deserializeCardData(allocator: std.mem.Allocator, reader: anytype) !item.CardData { const cardholder = try readOptionalString(allocator, reader); + errdefer if (cardholder) |c| allocator.free(c); + const number = try readOptionalString(allocator, reader); + errdefer if (number) |n| { + @memset(@constCast(n), 0); + allocator.free(n); + }; + const brand_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; - const brand: ?item.CardBrand = if (brand_byte == 0) null else std.meta.intToEnum(item.CardBrand, brand_byte) catch null; + const brand: ?item.CardBrand = if (brand_byte == 0) null else std.meta.intToEnum(item.CardBrand, brand_byte) catch return DeserializeError.InvalidData; const exp_month_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; const exp_month: ?u8 = if (exp_month_byte == 0) null else exp_month_byte; const exp_year_val = reader.readInt(u16, .little) catch return DeserializeError.UnexpectedEndOfData; const exp_year: ?u16 = if (exp_year_val == 0) null else exp_year_val; + const cvv = try readOptionalString(allocator, reader); + errdefer if (cvv) |c| { + @memset(@constCast(c), 0); + allocator.free(c); + }; + const pin = try readOptionalString(allocator, reader); + errdefer if (pin) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + }; return .{ .cardholder_name = cardholder, @@ -447,29 +529,77 @@ fn deserializeCardData(allocator: std.mem.Allocator, reader: anytype) !item.Card fn deserializeIdentityData(allocator: std.mem.Allocator, reader: anytype) !item.IdentityData { const title = try readOptionalString(allocator, reader); + errdefer if (title) |t| allocator.free(t); + const first_name = try readOptionalString(allocator, reader); + errdefer if (first_name) |f| allocator.free(f); + const middle_name = try readOptionalString(allocator, reader); + errdefer if (middle_name) |m| allocator.free(m); + const last_name = try readOptionalString(allocator, reader); + errdefer if (last_name) |l| allocator.free(l); + const email = try readOptionalString(allocator, reader); + errdefer if (email) |e| allocator.free(e); + const phone = try readOptionalString(allocator, reader); + errdefer if (phone) |p| allocator.free(p); const has_address = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; var address: ?item.AddressData = null; if (has_address == 1) { + const street1 = try readOptionalString(allocator, reader); + errdefer if (street1) |s| allocator.free(s); + const street2 = try readOptionalString(allocator, reader); + errdefer if (street2) |s| allocator.free(s); + const city = try readOptionalString(allocator, reader); + errdefer if (city) |c| allocator.free(c); + const state = try readOptionalString(allocator, reader); + errdefer if (state) |s| allocator.free(s); + const postal_code = try readOptionalString(allocator, reader); + errdefer if (postal_code) |p| allocator.free(p); + const country = try readOptionalString(allocator, reader); + errdefer if (country) |c| allocator.free(c); address = .{ - .street1 = try readOptionalString(allocator, reader), - .street2 = try readOptionalString(allocator, reader), - .city = try readOptionalString(allocator, reader), - .state = try readOptionalString(allocator, reader), - .postal_code = try readOptionalString(allocator, reader), - .country = try readOptionalString(allocator, reader), + .street1 = street1, + .street2 = street2, + .city = city, + .state = state, + .postal_code = postal_code, + .country = country, }; } + errdefer if (address) |a| { + if (a.street1) |s| allocator.free(s); + if (a.street2) |s| allocator.free(s); + if (a.city) |c| allocator.free(c); + if (a.state) |s| allocator.free(s); + if (a.postal_code) |p| allocator.free(p); + if (a.country) |c| allocator.free(c); + }; const ssn = try readOptionalString(allocator, reader); + errdefer if (ssn) |s| { + @memset(@constCast(s), 0); + allocator.free(s); + }; + const passport = try readOptionalString(allocator, reader); + errdefer if (passport) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + }; + const license_number = try readOptionalString(allocator, reader); + errdefer if (license_number) |l| { + @memset(@constCast(l), 0); + allocator.free(l); + }; + const company = try readOptionalString(allocator, reader); + errdefer if (company) |c| allocator.free(c); + const job_title = try readOptionalString(allocator, reader); return .{ @@ -490,10 +620,20 @@ fn deserializeIdentityData(allocator: std.mem.Allocator, reader: anytype) !item. fn deserializeSshKeyData(allocator: std.mem.Allocator, reader: anytype) !item.SshKeyData { const private_key = try readString(allocator, reader); + errdefer { + @memset(@constCast(private_key), 0); + allocator.free(private_key); + } + const public_key = try readString(allocator, reader); + errdefer allocator.free(public_key); + const fingerprint = try readString(allocator, reader); + errdefer allocator.free(fingerprint); + const type_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; const key_type = std.meta.intToEnum(item.SshKeyType, type_byte) catch return DeserializeError.InvalidData; + const passphrase = try readOptionalString(allocator, reader); return .{ @@ -506,24 +646,59 @@ fn deserializeSshKeyData(allocator: std.mem.Allocator, reader: anytype) !item.Ss } fn deserializeApiCredentialData(allocator: std.mem.Allocator, reader: anytype) !item.ApiCredentialData { + const api_key = try readOptionalString(allocator, reader); + errdefer if (api_key) |k| { + @memset(@constCast(k), 0); + allocator.free(k); + }; + + const api_secret = try readOptionalString(allocator, reader); + errdefer if (api_secret) |s| { + @memset(@constCast(s), 0); + allocator.free(s); + }; + + const endpoint = try readOptionalString(allocator, reader); + errdefer if (endpoint) |e| allocator.free(e); + + const documentation_url = try readOptionalString(allocator, reader); + return .{ - .api_key = try readOptionalString(allocator, reader), - .api_secret = try readOptionalString(allocator, reader), - .endpoint = try readOptionalString(allocator, reader), - .documentation_url = try readOptionalString(allocator, reader), + .api_key = api_key, + .api_secret = api_secret, + .endpoint = endpoint, + .documentation_url = documentation_url, }; } fn deserializeDatabaseData(allocator: std.mem.Allocator, reader: anytype) !item.DatabaseData { const type_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; const db_type = std.meta.intToEnum(item.DatabaseType, type_byte) catch return DeserializeError.InvalidData; + const host = try readOptionalString(allocator, reader); + errdefer if (host) |h| allocator.free(h); + const port_val = reader.readInt(u16, .little) catch return DeserializeError.UnexpectedEndOfData; const port: ?u16 = if (port_val == 0) null else port_val; + const database = try readOptionalString(allocator, reader); + errdefer if (database) |d| allocator.free(d); + const username = try readOptionalString(allocator, reader); + errdefer if (username) |u| allocator.free(u); + const password = try readOptionalString(allocator, reader); + errdefer if (password) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + }; + const connection_string = try readOptionalString(allocator, reader); + errdefer if (connection_string) |c| { + @memset(@constCast(c), 0); + allocator.free(c); + }; + const sid = try readOptionalString(allocator, reader); return .{ @@ -540,7 +715,14 @@ fn deserializeDatabaseData(allocator: std.mem.Allocator, reader: anytype) !item. fn deserializeWifiData(allocator: std.mem.Allocator, reader: anytype) !item.WifiData { const ssid = try readString(allocator, reader); + errdefer allocator.free(ssid); + const password = try readOptionalString(allocator, reader); + errdefer if (password) |p| { + @memset(@constCast(p), 0); + allocator.free(p); + }; + const sec_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; const security = std.meta.intToEnum(item.WifiSecurity, sec_byte) catch return DeserializeError.InvalidData; const hidden_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; @@ -554,14 +736,35 @@ fn deserializeWifiData(allocator: std.mem.Allocator, reader: anytype) !item.Wifi } fn deserializeLicenseData(allocator: std.mem.Allocator, reader: anytype) !item.LicenseData { + const license_key = try readOptionalString(allocator, reader); + errdefer if (license_key) |k| { + @memset(@constCast(k), 0); + allocator.free(k); + }; + + const product_name = try readOptionalString(allocator, reader); + errdefer if (product_name) |p| allocator.free(p); + + const version = try readOptionalString(allocator, reader); + errdefer if (version) |v| allocator.free(v); + + const publisher = try readOptionalString(allocator, reader); + errdefer if (publisher) |p| allocator.free(p); + + const email = try readOptionalString(allocator, reader); + errdefer if (email) |e| allocator.free(e); + + const purchase_date = try readOptionalI64(reader); + const expiration_date = try readOptionalI64(reader); + return .{ - .license_key = try readOptionalString(allocator, reader), - .product_name = try readOptionalString(allocator, reader), - .version = try readOptionalString(allocator, reader), - .publisher = try readOptionalString(allocator, reader), - .email = try readOptionalString(allocator, reader), - .purchase_date = try readOptionalI64(reader), - .expiration_date = try readOptionalI64(reader), + .license_key = license_key, + .product_name = product_name, + .version = version, + .publisher = publisher, + .email = email, + .purchase_date = purchase_date, + .expiration_date = expiration_date, }; } diff --git a/src/core/vault.zig b/src/core/vault.zig index 4955838..45d4f95 100644 --- a/src/core/vault.zig +++ b/src/core/vault.zig @@ -19,7 +19,7 @@ pub const VaultError = error{ }; pub const VAULT_MAGIC: [4]u8 = .{ 'Z', 'A', 'U', 'L' }; -pub const VAULT_VERSION: u16 = 1; +pub const VAULT_VERSION: u16 = 2; pub const VaultHeader = struct { magic: [4]u8 = VAULT_MAGIC, From bedff6d6957d4833d230705b431bbca0750b0fe5 Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:44:31 +0100 Subject: [PATCH 16/19] refactor(core): rename items field to item_list for clarity Avoids confusing vault.items.items pattern by renaming to vault.item_list.items which reads more naturally. --- src/cli/commands/list.zig | 6 +++--- src/cli/commands/passkey.zig | 4 ++-- src/cli/commands/totp.zig | 4 ++-- src/core/vault.zig | 32 ++++++++++++++++---------------- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/cli/commands/list.zig b/src/cli/commands/list.zig index 89f295d..5c35895 100644 --- a/src/cli/commands/list.zig +++ b/src/cli/commands/list.zig @@ -6,16 +6,16 @@ pub fn run(allocator: std.mem.Allocator, _: []const []const u8, _: config.Config var ctx = vault_helpers.openAndUnlock(allocator) catch return; defer ctx.deinit(); - if (ctx.vault.items.items.len == 0) { + if (ctx.vault.item_list.items.len == 0) { std.debug.print("Vault is empty. Use 'zault add ' to add items.\n", .{}); return; } - std.debug.print("Items ({d}):\n", .{ctx.vault.items.items.len}); + std.debug.print("Items ({d}):\n", .{ctx.vault.item_list.items.len}); std.debug.print("{s:<30} {s:<12} {s}\n", .{ "NAME", "TYPE", "USERNAME/INFO" }); std.debug.print("{s}\n", .{"-" ** 60}); - for (ctx.vault.items.items) |i| { + for (ctx.vault.item_list.items) |i| { const type_str = switch (i.item_type) { .login => "login", .secure_note => "note", diff --git a/src/cli/commands/passkey.zig b/src/cli/commands/passkey.zig index 05e7bf3..dd54acd 100644 --- a/src/cli/commands/passkey.zig +++ b/src/cli/commands/passkey.zig @@ -39,7 +39,7 @@ fn runList(allocator: std.mem.Allocator) !void { defer ctx.deinit(); var count: usize = 0; - for (ctx.vault.items.items) |i| { + for (ctx.vault.item_list.items) |i| { if (i.item_type == .login and i.data.login.passkeys.len > 0) { count += i.data.login.passkeys.len; } @@ -54,7 +54,7 @@ fn runList(allocator: std.mem.Allocator) !void { std.debug.print("{s:<25} {s:<30} {s}\n", .{ "ITEM", "RP ID", "USER" }); std.debug.print("{s}\n", .{"-" ** 70}); - for (ctx.vault.items.items) |i| { + for (ctx.vault.item_list.items) |i| { if (i.item_type == .login) { for (i.data.login.passkeys) |pk| { const user = pk.user_name orelse ""; diff --git a/src/cli/commands/totp.zig b/src/cli/commands/totp.zig index 59f0308..3ee50de 100644 --- a/src/cli/commands/totp.zig +++ b/src/cli/commands/totp.zig @@ -224,7 +224,7 @@ fn runList(allocator: std.mem.Allocator) !void { defer ctx.deinit(); var count: usize = 0; - for (ctx.vault.items.items) |i| { + for (ctx.vault.item_list.items) |i| { if (i.item_type == .login and i.data.login.totp != null) { count += 1; } @@ -239,7 +239,7 @@ fn runList(allocator: std.mem.Allocator) !void { std.debug.print("{s:<30} {s}\n", .{ "NAME", "USERNAME" }); std.debug.print("{s}\n", .{"-" ** 45}); - for (ctx.vault.items.items) |i| { + for (ctx.vault.item_list.items) |i| { if (i.item_type == .login and i.data.login.totp != null) { const username = i.data.login.username orelse ""; std.debug.print("{s:<30} {s}\n", .{ i.name, username }); diff --git a/src/core/vault.zig b/src/core/vault.zig index 45d4f95..79cb160 100644 --- a/src/core/vault.zig +++ b/src/core/vault.zig @@ -129,7 +129,7 @@ pub const VaultHeader = struct { pub const Vault = struct { allocator: std.mem.Allocator, header: VaultHeader, - items: std.ArrayList(item.Item), + item_list: std.ArrayList(item.Item), is_locked: bool, path: []const u8, derived_key: ?[argon2.key_length]u8, @@ -143,7 +143,7 @@ pub const Vault = struct { .salt = undefined, .nonce = undefined, }, - .items = .empty, + .item_list = .empty, .is_locked = true, .path = path, .derived_key = null, @@ -151,10 +151,10 @@ pub const Vault = struct { } pub fn deinit(self: *Self) void { - for (self.items.items) |*i| { + for (self.item_list.items) |*i| { i.deinit(self.allocator); } - self.items.deinit(self.allocator); + self.item_list.deinit(self.allocator); if (self.derived_key) |*key| { memory.secureZero(key); @@ -199,7 +199,7 @@ pub const Vault = struct { return Self{ .allocator = allocator, .header = header, - .items = .empty, + .item_list = .empty, .is_locked = true, .path = path, .derived_key = null, @@ -281,7 +281,7 @@ pub const Vault = struct { const items_slice = serializer.deserializeItems(self.allocator, plaintext) catch return VaultError.VaultCorrupted; for (items_slice) |i| { - self.items.append(self.allocator, i) catch { + self.item_list.append(self.allocator, i) catch { for (items_slice) |*it| { var mut_it = it.*; mut_it.deinit(self.allocator); @@ -296,10 +296,10 @@ pub const Vault = struct { } pub fn lock(self: *Self) void { - for (self.items.items) |*i| { + for (self.item_list.items) |*i| { i.deinit(self.allocator); } - self.items.clearRetainingCapacity(); + self.item_list.clearRetainingCapacity(); self.clearKey(); self.is_locked = true; } @@ -313,7 +313,7 @@ pub const Vault = struct { std.crypto.random.bytes(&self.header.nonce); - const plaintext = serializer.serializeItems(self.allocator, self.items.items) catch return VaultError.IoError; + const plaintext = serializer.serializeItems(self.allocator, self.item_list.items) catch return VaultError.IoError; defer { memory.secureZero(plaintext); self.allocator.free(plaintext); @@ -345,13 +345,13 @@ pub const Vault = struct { return VaultError.VaultLocked; } - for (self.items.items) |i| { + for (self.item_list.items) |i| { if (std.mem.eql(u8, i.name, new_item.name)) { return VaultError.ItemAlreadyExists; } } - try self.items.append(self.allocator, new_item); + try self.item_list.append(self.allocator, new_item); } pub fn getItem(self: *Self, name: []const u8) ?*item.Item { @@ -359,7 +359,7 @@ pub const Vault = struct { return null; } - for (self.items.items) |*i| { + for (self.item_list.items) |*i| { if (std.mem.eql(u8, i.name, name)) { return i; } @@ -373,10 +373,10 @@ pub const Vault = struct { return VaultError.VaultLocked; } - for (self.items.items, 0..) |*i, idx| { + for (self.item_list.items, 0..) |*i, idx| { if (std.mem.eql(u8, i.name, name)) { i.deinit(self.allocator); - _ = self.items.orderedRemove(idx); + _ = self.item_list.orderedRemove(idx); return; } } @@ -432,7 +432,7 @@ test "vault init" { defer vault.deinit(); try std.testing.expect(vault.is_locked); - try std.testing.expectEqual(@as(usize, 0), vault.items.items.len); + try std.testing.expectEqual(@as(usize, 0), vault.item_list.items.len); } test "vault header serialize deserialize" { @@ -481,7 +481,7 @@ test "vault create save open unlock roundtrip" { try vault.unlock("testpassword123"); try std.testing.expect(!vault.is_locked); - try std.testing.expectEqual(@as(usize, 1), vault.items.items.len); + try std.testing.expectEqual(@as(usize, 1), vault.item_list.items.len); const retrieved = vault.getItem("github.com"); try std.testing.expect(retrieved != null); From 3c141e12bad1d29bce89a7fa686d775a88573b1d Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:53:31 +0100 Subject: [PATCH 17/19] refactor(memory): add helper functions for secure/optional free - Add secureZeroAndFree for non-optional sensitive data - Add secureFree for optional sensitive data (zero then free) - Add freeOptional for optional non-sensitive data - Refactor item.zig deinit functions to use new helpers - Refactor serializer.zig errdefer blocks to use new helpers - Reduces code duplication by 76 lines --- src/core/item.zig | 140 +++++++++++------------------- src/core/serializer.zig | 148 +++++++++++--------------------- src/memory/secure_allocator.zig | 16 ++++ 3 files changed, 114 insertions(+), 190 deletions(-) diff --git a/src/core/item.zig b/src/core/item.zig index bba9408..1c82ce6 100644 --- a/src/core/item.zig +++ b/src/core/item.zig @@ -1,5 +1,6 @@ const std = @import("std"); const Uuid = @import("uuid.zig").Uuid; +const memory = @import("../memory/secure_allocator.zig"); pub const ItemType = enum(u8) { login = 1, @@ -33,7 +34,7 @@ pub const CustomField = struct { pub fn deinit(self: *CustomField, allocator: std.mem.Allocator) void { allocator.free(self.name); - if (self.value) |v| allocator.free(v); + memory.freeOptional(allocator, self.value); } }; @@ -42,8 +43,7 @@ pub const PasswordHistoryEntry = struct { changed_at: i64, pub fn deinit(self: *PasswordHistoryEntry, allocator: std.mem.Allocator) void { - @memset(@constCast(self.password), 0); - allocator.free(self.password); + memory.secureFree(allocator, self.password); } }; @@ -66,7 +66,7 @@ pub const Item = struct { pub fn deinit(self: *Self, allocator: std.mem.Allocator) void { allocator.free(self.name); - if (self.notes) |n| allocator.free(n); + memory.freeOptional(allocator, self.notes); for (self.fields) |*f| f.deinit(allocator); if (self.fields.len > 0) allocator.free(self.fields); @@ -122,8 +122,7 @@ pub const TotpData = struct { period: u32 = 30, pub fn deinit(self: *TotpData, allocator: std.mem.Allocator) void { - @memset(@constCast(self.secret), 0); - allocator.free(self.secret); + memory.secureFree(allocator, self.secret); } }; @@ -164,13 +163,12 @@ pub const PasskeyData = struct { pub fn deinit(self: *PasskeyData, allocator: std.mem.Allocator) void { allocator.free(self.credential_id); - @memset(@constCast(self.private_key), 0); - allocator.free(self.private_key); + memory.secureFree(allocator, self.private_key); allocator.free(self.public_key); allocator.free(self.rp_id); - if (self.rp_name) |n| allocator.free(n); + memory.freeOptional(allocator, self.rp_name); allocator.free(self.user_handle); - if (self.user_name) |n| allocator.free(n); + memory.freeOptional(allocator, self.user_name); } }; @@ -182,11 +180,8 @@ pub const LoginData = struct { passkeys: []PasskeyData = &.{}, pub fn deinit(self: *LoginData, allocator: std.mem.Allocator) void { - if (self.username) |u| allocator.free(u); - if (self.password) |p| { - @memset(@constCast(p), 0); - allocator.free(p); - } + memory.freeOptional(allocator, self.username); + memory.secureFree(allocator, self.password); for (self.uris) |*uri| uri.deinit(allocator); if (self.uris.len > 0) allocator.free(self.uris); if (self.totp) |*t| { @@ -225,19 +220,10 @@ pub const CardData = struct { pin: ?[]const u8 = null, pub fn deinit(self: *CardData, allocator: std.mem.Allocator) void { - if (self.cardholder_name) |n| allocator.free(n); - if (self.number) |n| { - @memset(@constCast(n), 0); - allocator.free(n); - } - if (self.cvv) |c| { - @memset(@constCast(c), 0); - allocator.free(c); - } - if (self.pin) |p| { - @memset(@constCast(p), 0); - allocator.free(p); - } + memory.freeOptional(allocator, self.cardholder_name); + memory.secureFree(allocator, self.number); + memory.secureFree(allocator, self.cvv); + memory.secureFree(allocator, self.pin); } pub fn lastFour(self: *const CardData) ?[]const u8 { @@ -257,12 +243,12 @@ pub const AddressData = struct { country: ?[]const u8 = null, pub fn deinit(self: *AddressData, allocator: std.mem.Allocator) void { - if (self.street1) |s| allocator.free(s); - if (self.street2) |s| allocator.free(s); - if (self.city) |c| allocator.free(c); - if (self.state) |s| allocator.free(s); - if (self.postal_code) |p| allocator.free(p); - if (self.country) |c| allocator.free(c); + memory.freeOptional(allocator, self.street1); + memory.freeOptional(allocator, self.street2); + memory.freeOptional(allocator, self.city); + memory.freeOptional(allocator, self.state); + memory.freeOptional(allocator, self.postal_code); + memory.freeOptional(allocator, self.country); } }; @@ -281,27 +267,21 @@ pub const IdentityData = struct { job_title: ?[]const u8 = null, pub fn deinit(self: *IdentityData, allocator: std.mem.Allocator) void { - if (self.title) |t| allocator.free(t); - if (self.first_name) |n| allocator.free(n); - if (self.middle_name) |n| allocator.free(n); - if (self.last_name) |n| allocator.free(n); - if (self.email) |e| allocator.free(e); - if (self.phone) |p| allocator.free(p); + memory.freeOptional(allocator, self.title); + memory.freeOptional(allocator, self.first_name); + memory.freeOptional(allocator, self.middle_name); + memory.freeOptional(allocator, self.last_name); + memory.freeOptional(allocator, self.email); + memory.freeOptional(allocator, self.phone); if (self.address) |*a| { var addr = a.*; addr.deinit(allocator); } - if (self.ssn) |s| { - @memset(@constCast(s), 0); - allocator.free(s); - } - if (self.passport) |p| { - @memset(@constCast(p), 0); - allocator.free(p); - } - if (self.license_number) |l| allocator.free(l); - if (self.company) |c| allocator.free(c); - if (self.job_title) |j| allocator.free(j); + memory.secureFree(allocator, self.ssn); + memory.secureFree(allocator, self.passport); + memory.freeOptional(allocator, self.license_number); + memory.freeOptional(allocator, self.company); + memory.freeOptional(allocator, self.job_title); } pub fn fullName(self: *const IdentityData, allocator: std.mem.Allocator) ![]const u8 { @@ -331,14 +311,10 @@ pub const SshKeyData = struct { passphrase: ?[]const u8 = null, pub fn deinit(self: *SshKeyData, allocator: std.mem.Allocator) void { - @memset(@constCast(self.private_key), 0); - allocator.free(self.private_key); + memory.secureFree(allocator, self.private_key); allocator.free(self.public_key); allocator.free(self.fingerprint); - if (self.passphrase) |p| { - @memset(@constCast(p), 0); - allocator.free(p); - } + memory.secureFree(allocator, self.passphrase); } }; @@ -349,16 +325,10 @@ pub const ApiCredentialData = struct { documentation_url: ?[]const u8 = null, pub fn deinit(self: *ApiCredentialData, allocator: std.mem.Allocator) void { - if (self.api_key) |k| { - @memset(@constCast(k), 0); - allocator.free(k); - } - if (self.api_secret) |s| { - @memset(@constCast(s), 0); - allocator.free(s); - } - if (self.endpoint) |e| allocator.free(e); - if (self.documentation_url) |d| allocator.free(d); + memory.secureFree(allocator, self.api_key); + memory.secureFree(allocator, self.api_secret); + memory.freeOptional(allocator, self.endpoint); + memory.freeOptional(allocator, self.documentation_url); } }; @@ -385,18 +355,12 @@ pub const DatabaseData = struct { sid: ?[]const u8 = null, pub fn deinit(self: *DatabaseData, allocator: std.mem.Allocator) void { - if (self.host) |h| allocator.free(h); - if (self.database) |d| allocator.free(d); - if (self.username) |u| allocator.free(u); - if (self.password) |p| { - @memset(@constCast(p), 0); - allocator.free(p); - } - if (self.connection_string) |c| { - @memset(@constCast(c), 0); - allocator.free(c); - } - if (self.sid) |s| allocator.free(s); + memory.freeOptional(allocator, self.host); + memory.freeOptional(allocator, self.database); + memory.freeOptional(allocator, self.username); + memory.secureFree(allocator, self.password); + memory.secureFree(allocator, self.connection_string); + memory.freeOptional(allocator, self.sid); } }; @@ -416,10 +380,7 @@ pub const WifiData = struct { pub fn deinit(self: *WifiData, allocator: std.mem.Allocator) void { allocator.free(self.ssid); - if (self.password) |p| { - @memset(@constCast(p), 0); - allocator.free(p); - } + memory.secureFree(allocator, self.password); } }; @@ -433,14 +394,11 @@ pub const LicenseData = struct { expiration_date: ?i64 = null, pub fn deinit(self: *LicenseData, allocator: std.mem.Allocator) void { - if (self.license_key) |k| { - @memset(@constCast(k), 0); - allocator.free(k); - } - if (self.product_name) |p| allocator.free(p); - if (self.version) |v| allocator.free(v); - if (self.publisher) |p| allocator.free(p); - if (self.email) |e| allocator.free(e); + memory.secureFree(allocator, self.license_key); + memory.freeOptional(allocator, self.product_name); + memory.freeOptional(allocator, self.version); + memory.freeOptional(allocator, self.publisher); + memory.freeOptional(allocator, self.email); } }; diff --git a/src/core/serializer.zig b/src/core/serializer.zig index 1b39c63..8025528 100644 --- a/src/core/serializer.zig +++ b/src/core/serializer.zig @@ -1,6 +1,7 @@ const std = @import("std"); const item = @import("item.zig"); const Uuid = @import("uuid.zig").Uuid; +const memory = @import("../memory/secure_allocator.zig"); pub const SerializeError = error{ BufferTooSmall, @@ -307,10 +308,7 @@ fn deserializeItem(allocator: std.mem.Allocator, reader: anytype) !item.Item { for (0..history_count) |i| { const pw = try readString(allocator, reader); - errdefer { - @memset(@constCast(pw), 0); - allocator.free(pw); - } + errdefer memory.secureZeroAndFree(allocator, pw); const changed_at = reader.readInt(i64, .little) catch return DeserializeError.UnexpectedEndOfData; history[i] = .{ .password = pw, .changed_at = changed_at }; history_initialized = i + 1; @@ -368,13 +366,10 @@ fn deserializeItemData(allocator: std.mem.Allocator, reader: anytype, item_type: fn deserializeLoginData(allocator: std.mem.Allocator, reader: anytype) !item.LoginData { const username = try readOptionalString(allocator, reader); - errdefer if (username) |u| allocator.free(u); + errdefer memory.freeOptional(allocator, username); const password = try readOptionalString(allocator, reader); - errdefer if (password) |p| { - @memset(@constCast(p), 0); - allocator.free(p); - }; + errdefer memory.secureFree(allocator, password); const uri_count = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; var uris = try allocator.alloc(item.Uri, uri_count); @@ -399,10 +394,7 @@ fn deserializeLoginData(allocator: std.mem.Allocator, reader: anytype) !item.Log var totp: ?item.TotpData = null; if (has_totp == 1) { const secret = try readString(allocator, reader); - errdefer { - @memset(@constCast(secret), 0); - allocator.free(secret); - } + errdefer memory.secureZeroAndFree(allocator, secret); const algo_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; const algorithm = std.meta.intToEnum(item.TotpAlgorithm, algo_byte) catch return DeserializeError.InvalidData; const digits = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; @@ -449,10 +441,7 @@ fn deserializePasskeyData(allocator: std.mem.Allocator, reader: anytype) !item.P errdefer allocator.free(credential_id); const private_key = try readString(allocator, reader); - errdefer { - @memset(@constCast(private_key), 0); - allocator.free(private_key); - } + errdefer memory.secureZeroAndFree(allocator, private_key); const public_key = try readString(allocator, reader); errdefer allocator.free(public_key); @@ -464,13 +453,13 @@ fn deserializePasskeyData(allocator: std.mem.Allocator, reader: anytype) !item.P errdefer allocator.free(rp_id); const rp_name = try readOptionalString(allocator, reader); - errdefer if (rp_name) |n| allocator.free(n); + errdefer memory.freeOptional(allocator, rp_name); const user_handle = try readString(allocator, reader); errdefer allocator.free(user_handle); const user_name = try readOptionalString(allocator, reader); - errdefer if (user_name) |n| allocator.free(n); + errdefer memory.freeOptional(allocator, user_name); const counter = reader.readInt(u32, .little) catch return DeserializeError.UnexpectedEndOfData; @@ -489,13 +478,10 @@ fn deserializePasskeyData(allocator: std.mem.Allocator, reader: anytype) !item.P fn deserializeCardData(allocator: std.mem.Allocator, reader: anytype) !item.CardData { const cardholder = try readOptionalString(allocator, reader); - errdefer if (cardholder) |c| allocator.free(c); + errdefer memory.freeOptional(allocator, cardholder); const number = try readOptionalString(allocator, reader); - errdefer if (number) |n| { - @memset(@constCast(n), 0); - allocator.free(n); - }; + errdefer memory.secureFree(allocator, number); const brand_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; const brand: ?item.CardBrand = if (brand_byte == 0) null else std.meta.intToEnum(item.CardBrand, brand_byte) catch return DeserializeError.InvalidData; @@ -505,16 +491,10 @@ fn deserializeCardData(allocator: std.mem.Allocator, reader: anytype) !item.Card const exp_year: ?u16 = if (exp_year_val == 0) null else exp_year_val; const cvv = try readOptionalString(allocator, reader); - errdefer if (cvv) |c| { - @memset(@constCast(c), 0); - allocator.free(c); - }; + errdefer memory.secureFree(allocator, cvv); const pin = try readOptionalString(allocator, reader); - errdefer if (pin) |p| { - @memset(@constCast(p), 0); - allocator.free(p); - }; + errdefer memory.secureFree(allocator, pin); return .{ .cardholder_name = cardholder, @@ -529,38 +509,38 @@ fn deserializeCardData(allocator: std.mem.Allocator, reader: anytype) !item.Card fn deserializeIdentityData(allocator: std.mem.Allocator, reader: anytype) !item.IdentityData { const title = try readOptionalString(allocator, reader); - errdefer if (title) |t| allocator.free(t); + errdefer memory.freeOptional(allocator, title); const first_name = try readOptionalString(allocator, reader); - errdefer if (first_name) |f| allocator.free(f); + errdefer memory.freeOptional(allocator, first_name); const middle_name = try readOptionalString(allocator, reader); - errdefer if (middle_name) |m| allocator.free(m); + errdefer memory.freeOptional(allocator, middle_name); const last_name = try readOptionalString(allocator, reader); - errdefer if (last_name) |l| allocator.free(l); + errdefer memory.freeOptional(allocator, last_name); const email = try readOptionalString(allocator, reader); - errdefer if (email) |e| allocator.free(e); + errdefer memory.freeOptional(allocator, email); const phone = try readOptionalString(allocator, reader); - errdefer if (phone) |p| allocator.free(p); + errdefer memory.freeOptional(allocator, phone); const has_address = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; var address: ?item.AddressData = null; if (has_address == 1) { const street1 = try readOptionalString(allocator, reader); - errdefer if (street1) |s| allocator.free(s); + errdefer memory.freeOptional(allocator, street1); const street2 = try readOptionalString(allocator, reader); - errdefer if (street2) |s| allocator.free(s); + errdefer memory.freeOptional(allocator, street2); const city = try readOptionalString(allocator, reader); - errdefer if (city) |c| allocator.free(c); + errdefer memory.freeOptional(allocator, city); const state = try readOptionalString(allocator, reader); - errdefer if (state) |s| allocator.free(s); + errdefer memory.freeOptional(allocator, state); const postal_code = try readOptionalString(allocator, reader); - errdefer if (postal_code) |p| allocator.free(p); + errdefer memory.freeOptional(allocator, postal_code); const country = try readOptionalString(allocator, reader); - errdefer if (country) |c| allocator.free(c); + errdefer memory.freeOptional(allocator, country); address = .{ .street1 = street1, .street2 = street2, @@ -571,34 +551,25 @@ fn deserializeIdentityData(allocator: std.mem.Allocator, reader: anytype) !item. }; } errdefer if (address) |a| { - if (a.street1) |s| allocator.free(s); - if (a.street2) |s| allocator.free(s); - if (a.city) |c| allocator.free(c); - if (a.state) |s| allocator.free(s); - if (a.postal_code) |p| allocator.free(p); - if (a.country) |c| allocator.free(c); + memory.freeOptional(allocator, a.street1); + memory.freeOptional(allocator, a.street2); + memory.freeOptional(allocator, a.city); + memory.freeOptional(allocator, a.state); + memory.freeOptional(allocator, a.postal_code); + memory.freeOptional(allocator, a.country); }; const ssn = try readOptionalString(allocator, reader); - errdefer if (ssn) |s| { - @memset(@constCast(s), 0); - allocator.free(s); - }; + errdefer memory.secureFree(allocator, ssn); const passport = try readOptionalString(allocator, reader); - errdefer if (passport) |p| { - @memset(@constCast(p), 0); - allocator.free(p); - }; + errdefer memory.secureFree(allocator, passport); const license_number = try readOptionalString(allocator, reader); - errdefer if (license_number) |l| { - @memset(@constCast(l), 0); - allocator.free(l); - }; + errdefer memory.freeOptional(allocator, license_number); const company = try readOptionalString(allocator, reader); - errdefer if (company) |c| allocator.free(c); + errdefer memory.freeOptional(allocator, company); const job_title = try readOptionalString(allocator, reader); @@ -620,10 +591,7 @@ fn deserializeIdentityData(allocator: std.mem.Allocator, reader: anytype) !item. fn deserializeSshKeyData(allocator: std.mem.Allocator, reader: anytype) !item.SshKeyData { const private_key = try readString(allocator, reader); - errdefer { - @memset(@constCast(private_key), 0); - allocator.free(private_key); - } + errdefer memory.secureZeroAndFree(allocator, private_key); const public_key = try readString(allocator, reader); errdefer allocator.free(public_key); @@ -647,19 +615,13 @@ fn deserializeSshKeyData(allocator: std.mem.Allocator, reader: anytype) !item.Ss fn deserializeApiCredentialData(allocator: std.mem.Allocator, reader: anytype) !item.ApiCredentialData { const api_key = try readOptionalString(allocator, reader); - errdefer if (api_key) |k| { - @memset(@constCast(k), 0); - allocator.free(k); - }; + errdefer memory.secureFree(allocator, api_key); const api_secret = try readOptionalString(allocator, reader); - errdefer if (api_secret) |s| { - @memset(@constCast(s), 0); - allocator.free(s); - }; + errdefer memory.secureFree(allocator, api_secret); const endpoint = try readOptionalString(allocator, reader); - errdefer if (endpoint) |e| allocator.free(e); + errdefer memory.freeOptional(allocator, endpoint); const documentation_url = try readOptionalString(allocator, reader); @@ -676,28 +638,22 @@ fn deserializeDatabaseData(allocator: std.mem.Allocator, reader: anytype) !item. const db_type = std.meta.intToEnum(item.DatabaseType, type_byte) catch return DeserializeError.InvalidData; const host = try readOptionalString(allocator, reader); - errdefer if (host) |h| allocator.free(h); + errdefer memory.freeOptional(allocator, host); const port_val = reader.readInt(u16, .little) catch return DeserializeError.UnexpectedEndOfData; const port: ?u16 = if (port_val == 0) null else port_val; const database = try readOptionalString(allocator, reader); - errdefer if (database) |d| allocator.free(d); + errdefer memory.freeOptional(allocator, database); const username = try readOptionalString(allocator, reader); - errdefer if (username) |u| allocator.free(u); + errdefer memory.freeOptional(allocator, username); const password = try readOptionalString(allocator, reader); - errdefer if (password) |p| { - @memset(@constCast(p), 0); - allocator.free(p); - }; + errdefer memory.secureFree(allocator, password); const connection_string = try readOptionalString(allocator, reader); - errdefer if (connection_string) |c| { - @memset(@constCast(c), 0); - allocator.free(c); - }; + errdefer memory.secureFree(allocator, connection_string); const sid = try readOptionalString(allocator, reader); @@ -718,10 +674,7 @@ fn deserializeWifiData(allocator: std.mem.Allocator, reader: anytype) !item.Wifi errdefer allocator.free(ssid); const password = try readOptionalString(allocator, reader); - errdefer if (password) |p| { - @memset(@constCast(p), 0); - allocator.free(p); - }; + errdefer memory.secureFree(allocator, password); const sec_byte = reader.readByte() catch return DeserializeError.UnexpectedEndOfData; const security = std.meta.intToEnum(item.WifiSecurity, sec_byte) catch return DeserializeError.InvalidData; @@ -737,22 +690,19 @@ fn deserializeWifiData(allocator: std.mem.Allocator, reader: anytype) !item.Wifi fn deserializeLicenseData(allocator: std.mem.Allocator, reader: anytype) !item.LicenseData { const license_key = try readOptionalString(allocator, reader); - errdefer if (license_key) |k| { - @memset(@constCast(k), 0); - allocator.free(k); - }; + errdefer memory.secureFree(allocator, license_key); const product_name = try readOptionalString(allocator, reader); - errdefer if (product_name) |p| allocator.free(p); + errdefer memory.freeOptional(allocator, product_name); const version = try readOptionalString(allocator, reader); - errdefer if (version) |v| allocator.free(v); + errdefer memory.freeOptional(allocator, version); const publisher = try readOptionalString(allocator, reader); - errdefer if (publisher) |p| allocator.free(p); + errdefer memory.freeOptional(allocator, publisher); const email = try readOptionalString(allocator, reader); - errdefer if (email) |e| allocator.free(e); + errdefer memory.freeOptional(allocator, email); const purchase_date = try readOptionalI64(reader); const expiration_date = try readOptionalI64(reader); diff --git a/src/memory/secure_allocator.zig b/src/memory/secure_allocator.zig index 7e65125..6206d60 100644 --- a/src/memory/secure_allocator.zig +++ b/src/memory/secure_allocator.zig @@ -75,6 +75,22 @@ pub fn secureZero(buf: []u8) void { std.crypto.secureZero(u8, volatile_buf); } +// Zero sensitive data then free (non-optional) - use for passwords, keys, etc. +pub fn secureZeroAndFree(allocator: std.mem.Allocator, data: []const u8) void { + secureZero(@constCast(data)); + allocator.free(data); +} + +// Zero sensitive data then free (optional) - use for passwords, keys, etc. +pub fn secureFree(allocator: std.mem.Allocator, data: ?[]const u8) void { + if (data) |d| secureZeroAndFree(allocator, d); +} + +// Free optional non-sensitive data +pub fn freeOptional(allocator: std.mem.Allocator, data: ?[]const u8) void { + if (data) |d| allocator.free(d); +} + fn lockMemory(ptr: [*]u8, len: usize) !void { if (builtin.os.tag == .linux or builtin.os.tag == .macos) { const result = std.c.mlock(ptr, len); From f586fee4d8d2e33d49d3417d76363dfff025fe6a Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:57:56 +0100 Subject: [PATCH 18/19] docs: mark Phase 1 as completed with summary --- .../2026-01-28-phase1-core-data-model.md | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-01-28-phase1-core-data-model.md b/docs/plans/2026-01-28-phase1-core-data-model.md index c24f63d..4e0fe50 100644 --- a/docs/plans/2026-01-28-phase1-core-data-model.md +++ b/docs/plans/2026-01-28-phase1-core-data-model.md @@ -1,15 +1,52 @@ # Phase 1: Core Data Model Implementation -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. +> **Status: COMPLETED** (2026-01-28) **Goal:** Replace the current 3-type Entry system with a 9-type Item system supporting the full password manager data model. -**Architecture:** New `src/core/item.zig` defines all item types. New `src/core/serializer_v2.zig` handles the new format. The vault header version bumps from 1 to 2. On load, v1 vaults are migrated to v2 in memory and saved in v2 format. +**Architecture:** `src/core/item.zig` defines all 9 item types with proper memory management and secure zeroing. `src/core/serializer.zig` handles binary serialization. Vault version bumped to 2. **Tech Stack:** Zig 0.15, no new dependencies. --- +## Completion Summary + +**Commits:** 16 commits on `feat/data-model-v2` branch + +**Files Changed:** +- `src/core/uuid.zig` - New UUID v4 type +- `src/core/item.zig` - 9 item types with deinit, factory functions +- `src/core/serializer.zig` - Binary serialization for all types +- `src/core/vault.zig` - Updated to use item_list, version 2 +- `src/cli/commands/*.zig` - Updated for new Item API +- `src/memory/secure_allocator.zig` - Added helper functions +- `src/core/entry.zig` - Deleted (replaced by item.zig) + +**Item Types Implemented:** +1. login (with URIs, TOTP, passkeys) +2. secure_note +3. card (with brand detection) +4. identity (with address) +5. ssh_key +6. api_credential +7. database +8. wifi +9. license + +**Memory Safety:** +- Secure zeroing for all sensitive fields (passwords, keys, SSN, etc.) +- Proper errdefer cleanup in deserialization +- Helper functions: `secureFree`, `secureZeroAndFree`, `freeOptional` + +**Tests:** All 49 tests passing + +--- + +## Original Plan (for reference) + +--- + ## Task 1: Create UUID Type **Files:** From 335cd1623069ee946b54c9827d110a131f422fbd Mon Sep 17 00:00:00 2001 From: danpasecinic Date: Wed, 28 Jan 2026 19:58:28 +0100 Subject: [PATCH 19/19] docs: update README with accurate commands and cleaner format --- README.md | 104 ++++++++++++++++-------------------------------------- 1 file changed, 30 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 919e024..ba3efe7 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,36 @@ # zault -A secure credential manager written in Zig. Passwords, TOTP, and Passkeys. - -## Features - -- **Passwords** - Encrypted vault with Argon2id + XChaCha20-Poly1305 -- **TOTP** - Time-based one-time passwords (2FA) -- **Passkeys** - WebAuthn/FIDO2 software authenticator -- **Clipboard** - Auto-clearing clipboard integration -- **TUI** - Full terminal UI with fuzzy search -- **Browser** - Native messaging for passkey authentication +A command-line credential manager written in Zig. ## Installation -### From source - ```bash zig build -Doptimize=ReleaseSafe ``` -The binary will be in `zig-out/bin/zault`. - -### Requirements - -- Zig 0.13.0 or later +Binary will be at `zig-out/bin/zault`. Requires Zig 0.15+. -## Quick Start +## Usage ```bash -# Initialize a new vault +# Create a vault zault init -# Add a password +# Add credentials zault add github.com -u myuser -# Get a password (copies to clipboard) +# Retrieve (copies to clipboard) zault get github.com -# Add TOTP secret -zault totp add github.com +# List all items +zault list -# Get current TOTP code +# TOTP +zault totp add github.com zault totp github.com -# Generate a secure password +# Generate password zault generate - -# Launch TUI -zault ``` ## Commands @@ -55,63 +38,36 @@ zault | Command | Description | |---------|-------------| | `init` | Create a new vault | -| `add ` | Add a new credential | -| `get ` | Retrieve and copy credential | -| `list` | List all entries | -| `delete ` | Remove an entry | -| `generate` | Generate a secure password | +| `add ` | Add a credential | +| `get ` | Retrieve and copy to clipboard | +| `list` | List all items | +| `delete ` | Remove an item | +| `generate` | Generate a password | | `totp add ` | Add TOTP secret | -| `totp ` | Get current TOTP code | -| `passkey list` | List stored passkeys | -| `lock` | Lock the vault | -| `export` | Export vault (encrypted) | -| `import` | Import credentials | +| `totp ` | Get TOTP code | +| `totp list` | List items with TOTP | +| `passkey list` | List passkeys | +| `passkey show ` | Show passkey details | +| `unlock` | Unlock vault (start agent) | +| `lock` | Lock vault (stop agent) | ## Security -### Cryptographic Primitives - -| Purpose | Algorithm | -|---------|-----------| -| Key Derivation | Argon2id | -| Encryption | XChaCha20-Poly1305 | -| TOTP | HMAC-SHA1/SHA256/SHA512 | -| Passkeys | Ed25519 / ECDSA P-256 | +- **Key Derivation:** Argon2id +- **Encryption:** XChaCha20-Poly1305 +- **TOTP:** HMAC-SHA1/SHA256/SHA512 -### Memory Security +Sensitive data uses locked memory (`mlock`) and is zeroed before deallocation. -- Sensitive data is allocated in locked memory (`mlock`) -- All secrets are zeroed before deallocation -- No garbage collector - deterministic memory management - -### Vault Format - -The vault is stored as an encrypted binary file at `~/.config/zault/vault.zault`. - -See [SECURITY.md](docs/SECURITY.md) for detailed security information. +Vault stored at `~/.local/share/zault/vault.zault`. ## Development ```bash -# Build -zig build - -# Run tests -zig build test - -# Run with arguments -zig build run -- --help - -# Generate docs -zig build docs +zig build # build +zig build test # run tests ``` ## License -MIT License - see [LICENSE](LICENSE) for details. - -## Acknowledgments - -- [libsodium](https://libsodium.org/) - Cryptographic library -- [zig-clap](https://github.com/Hejsil/zig-clap) - CLI argument parsing -- [libvaxis](https://github.com/rockorager/libvaxis) - TUI framework +MIT