diff --git a/.gitignore b/.gitignore index 5c3e42d..db86c1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # dev env .direnv +list.json + # docs man diff --git a/TODOS.md b/TODOS.md index d2ecd1c..7948598 100644 --- a/TODOS.md +++ b/TODOS.md @@ -4,14 +4,14 @@ Note: These todos can wait until all the subcommands have been ported. ## HIGH -1. [x] **table.odin:74-89** — Hand-rolled JSON output doesn't escape `"`, `\`, newlines. Reimplements `json.marshal` which is already imported in `cmd_list.odin`. Replace with `json.marshal`. - 2. **db.odin:380-383, 405, 446** — `sqlite.bind_text` return values overwritten but never checked. A failed bind means `sqlite.step` operates on unbound params. 3. **config.odin:52-54** — `os.user_home_dir` error silently ignored. If it fails, `home` is `""` and all paths become relative (`".envr"` instead of `"~/.envr"`). 30. **cmd_sync.odin:46-50, 64-68** — Double `db_insert` when `BackedUp`: first insert on line 48, then `db_update_required` is also true for `BackedUp` so second insert runs on line 65. Redundant and wasteful. +31. **db.odin:626 & env_file.go:183** — `BackedUp` discards `DirUpdated`. When `TrustFilesystem` is used and the hash differs, the result is just `BackedUp` (not `BackedUp | DirUpdated`). If a file's directory was moved AND its contents changed, the old DB entry won't be deleted because the `DirUpdated` check at `cmd_sync.odin:59` never fires. Bug exists in both Go and Odin. + ## MEDIUM 4. **db.odin:29-35** — `make_temp_path` never calls `strings.builder_destroy`. Leaks builder buffer every call. @@ -38,8 +38,6 @@ Note: These todos can wait until all the subcommands have been ported. ## LOW -14. [x] **db.odin:338-341** — Unnecessary `strings.clone` before `filepath.dir` (which already returns a slice into the input). - 15. **db.odin:115** — `json.unmarshal_string` error not checked. Malformed JSON silently produces empty/partial data. 16. **db.odin:352-353** — `hex.encode` error ignored. `string(hex_bytes)` aliases the byte slice. diff --git a/cmd_deps.odin b/cmd_deps.odin index b73afe8..f87ffa2 100644 --- a/cmd_deps.odin +++ b/cmd_deps.odin @@ -20,11 +20,5 @@ cmd_deps :: proc(cmd: ^Command) { append(&rows, []string{"fd", "\u2717 Missing"}) } - if .Age in feats { - append(&rows, []string{"age", "\u2713 Available"}) - } else { - append(&rows, []string{"age", "\u2717 Missing"}) - } - render_table(headers, rows[:]) } diff --git a/cmd_init.odin b/cmd_init.odin index be2f13a..cc8d017 100644 --- a/cmd_init.odin +++ b/cmd_init.odin @@ -18,7 +18,8 @@ cmd_init :: proc(cmd: ^Command) { } if len(keys) == 0 { - fmt.println("No SSH private keys found in ~/.ssh") + fmt.println("No ssh-ed25519 keys found in ~/.ssh") + fmt.println("Generate one with: ssh-keygen -t ed25519") return } diff --git a/cmd_list.odin b/cmd_list.odin index 604ca9d..67d746c 100644 --- a/cmd_list.odin +++ b/cmd_list.odin @@ -6,52 +6,56 @@ import "core:path/filepath" import "core:strings" ListEntry :: struct { - Directory: string `json:"directory"`, - Path: string `json:"path"`, + Directory: string `json:"directory"`, + Path: string `json:"path"`, } cmd_list :: proc(cmd: ^Command) { - db, db_ok := db_open() - if !db_ok { - return - } - defer db_close(&db) + db, db_ok := db_open() + if !db_ok { + return + } + defer db_close(&db) - rows, list_ok := db_list(&db) - if !list_ok { - return - } - defer delete(rows) + rows, list_ok := db_list(&db) + if !list_ok { + return + } + defer delete(rows) - if is_tty() { - headers := []string{"Directory", "Path"} - table_rows := make([dynamic][]string, 0, len(rows)) + if is_tty() { + headers := []string{"Directory", "Path"} + table_rows := make([dynamic][]string, 0, len(rows), context.temp_allocator) - for row in rows { - dir_str := strings.concatenate({row.Dir, "/"}) - filename := filepath.base(row.Path) - row_slice := make([]string, 2) - row_slice[0] = dir_str - row_slice[1] = filename - append(&table_rows, row_slice) - } + for row in rows { + dir_str := strings.concatenate({row.Dir, "/"}, context.temp_allocator) + filename := filepath.base(row.Path) + row_slice := make([]string, 2) + row_slice[0] = dir_str + row_slice[1] = filename + append(&table_rows, row_slice) + } - render_table(headers, table_rows[:]) - } else { - entries: [dynamic]ListEntry - for row in rows { - filename := filepath.base(row.Path) - append(&entries, ListEntry{ - Directory = strings.concatenate({row.Dir, "/"}), - Path = filename, - }) - } + render_table(headers, table_rows[:]) + } else { + entries: [dynamic]ListEntry + for row in rows { + filename := filepath.base(row.Path) + append( + &entries, + ListEntry { + Directory = strings.concatenate({row.Dir, "/"}, context.temp_allocator), + Path = filename, + }, + ) + } - data, marshal_err := json.marshal(entries[:]) - if marshal_err != nil { - fmt.printf("Error marshaling JSON: %v\n", marshal_err) - return - } - fmt.println(string(data)) - } + data, marshal_err := json.marshal(entries[:]) + if marshal_err != nil { + fmt.printf("Error marshaling JSON: %v\n", marshal_err) + return + } + fmt.println(string(data)) + } } + diff --git a/config.odin b/config.odin index f9637c8..154dc1e 100644 --- a/config.odin +++ b/config.odin @@ -61,9 +61,9 @@ envr_dir :: proc() -> string { return dir } -data_age_path :: proc() -> string { +data_encrypted_path :: proc() -> string { dir := envr_dir() - path, _ := filepath.join([]string{dir, "data.age"}) + path, _ := filepath.join([]string{dir, "data.envr"}) return path } @@ -103,6 +103,9 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) { } full_path, _ := filepath.join([]string{ssh_dir, name}) + if !is_ed25519_key(full_path) { + continue + } append(&keys, full_path) } diff --git a/crypto.odin b/crypto.odin new file mode 100644 index 0000000..44f39be --- /dev/null +++ b/crypto.odin @@ -0,0 +1,313 @@ +package main + +import "core:fmt" +import "core:mem" + +MAGIC :: "ENVR" +MAGIC_BYTES := [4]u8{u8('E'), u8('N'), u8('V'), u8('R')} + +RECIPIENT_ENTRY_SIZE :: CRYPTO_BOX_PUBLICKEY_BYTES + CRYPTO_BOX_NONCE_BYTES + CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES + +HEADER_SIZE :: 4 + CRYPTO_BOX_PUBLICKEY_BYTES + CRYPTO_SECRETBOX_NONCE_BYTES + 4 + +RecipientEntry :: struct { + PublicKey: [CRYPTO_BOX_PUBLICKEY_BYTES]u8, + Nonce: [CRYPTO_BOX_NONCE_BYTES]u8, + EncryptedKey: [CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES]u8, +} + +sodium_initialized: bool + +ensure_sodium :: proc() -> bool { + if sodium_initialized { + return true + } + rc := sodium_init() + if rc < 0 { + fmt.println("Error: libsodium initialization failed") + return false + } + sodium_initialized = true + return true +} + +X25519Keypair :: struct { + Public: [CRYPTO_BOX_PUBLICKEY_BYTES]u8, + Private: [CRYPTO_BOX_SECRETKEY_BYTES]u8, +} + +ssh_to_x25519 :: proc(keys: []SshKeyPair) -> (pairs: []X25519Keypair, ok: bool) { + if len(keys) == 0 { + return + } + + pairs = make([]X25519Keypair, len(keys)) + + for i in 0 ..< len(keys) { + ssh_kp, parse_ok := parse_ssh_private_key(keys[i].Private) + if !parse_ok { + fmt.printf("Error: failed to parse SSH private key: %s\n", keys[i].Private) + delete(pairs) + return + } + + ssh_pub, pub_ok := parse_ssh_public_key(keys[i].Public) + if !pub_ok { + fmt.printf("Error: failed to parse SSH public key: %s\n", keys[i].Public) + delete(pairs) + return + } + + pk_rc := crypto_sign_ed25519_pk_to_curve25519(&pairs[i].Public[0], &ssh_pub[0]) + if pk_rc != 0 { + fmt.println("Error: failed to convert ed25519 public key to curve25519") + delete(pairs) + return + } + + ed25519_sk: [64]u8 + for j in 0 ..< 32 { + ed25519_sk[j] = ssh_kp.Private[j] + } + for j in 0 ..< 32 { + ed25519_sk[32 + j] = ssh_kp.Public[j] + } + + sk_rc := crypto_sign_ed25519_sk_to_curve25519(&pairs[i].Private[0], &ed25519_sk[0]) + if sk_rc != 0 { + fmt.println("Error: failed to convert ed25519 private key to curve25519") + delete(pairs) + return + } + } + + ok = true + return +} + +encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: bool) { + if !ensure_sodium() { + return + } + + x25519_pairs, pairs_ok := ssh_to_x25519(keys) + if !pairs_ok { + return + } + defer delete(x25519_pairs) + + sym_key: [CRYPTO_SECRETBOX_KEY_BYTES]u8 + randombytes_buf(&sym_key[0], CRYPTO_SECRETBOX_KEY_BYTES) + + main_nonce: [CRYPTO_SECRETBOX_NONCE_BYTES]u8 + randombytes_buf(&main_nonce[0], CRYPTO_SECRETBOX_NONCE_BYTES) + + ct_len := len(plaintext) + CRYPTO_SECRETBOX_MAC_BYTES + secret_ct := make([]u8, ct_len) + pt_ptr: [^]u8 + if len(plaintext) > 0 { + pt_ptr = &plaintext[0] + } + rc := crypto_secretbox_easy(&secret_ct[0], pt_ptr, u64(len(plaintext)), &main_nonce[0], &sym_key[0]) + if rc != 0 { + fmt.println("Error: symmetric encryption failed") + delete(secret_ct) + return + } + + num_recipients := u32(len(x25519_pairs)) + entries := make([]RecipientEntry, num_recipients) + + for i in 0 ..< len(x25519_pairs) { + for j in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES { + entries[i].PublicKey[j] = x25519_pairs[i].Public[j] + } + + randombytes_buf(&entries[i].Nonce[0], CRYPTO_BOX_NONCE_BYTES) + + rc = crypto_box_easy( + &entries[i].EncryptedKey[0], + &sym_key[0], + CRYPTO_SECRETBOX_KEY_BYTES, + &entries[i].Nonce[0], + &x25519_pairs[i].Public[0], + &x25519_pairs[0].Private[0], + ) + if rc != 0 { + fmt.printf("Error: failed to encrypt for recipient %d\n", i) + delete(entries) + delete(secret_ct) + return + } + } + + total_len := HEADER_SIZE + int(num_recipients) * RECIPIENT_ENTRY_SIZE + ct_len + ciphertext = make([]u8, total_len) + + pos := 0 + + mem.copy(&ciphertext[pos], &MAGIC_BYTES[0], 4) + pos += 4 + + mem.copy(&ciphertext[pos], &x25519_pairs[0].Public[0], CRYPTO_BOX_PUBLICKEY_BYTES) + pos += CRYPTO_BOX_PUBLICKEY_BYTES + + mem.copy(&ciphertext[pos], &main_nonce[0], CRYPTO_SECRETBOX_NONCE_BYTES) + pos += CRYPTO_SECRETBOX_NONCE_BYTES + + ciphertext[pos] = u8((num_recipients >> 24) & 0xff) + ciphertext[pos + 1] = u8((num_recipients >> 16) & 0xff) + ciphertext[pos + 2] = u8((num_recipients >> 8) & 0xff) + ciphertext[pos + 3] = u8(num_recipients & 0xff) + pos += 4 + + for i in 0 ..< int(num_recipients) { + mem.copy(&ciphertext[pos], &entries[i].PublicKey[0], CRYPTO_BOX_PUBLICKEY_BYTES) + pos += CRYPTO_BOX_PUBLICKEY_BYTES + mem.copy(&ciphertext[pos], &entries[i].Nonce[0], CRYPTO_BOX_NONCE_BYTES) + pos += CRYPTO_BOX_NONCE_BYTES + mem.copy(&ciphertext[pos], &entries[i].EncryptedKey[0], CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES) + pos += CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES + } + + mem.copy(&ciphertext[pos], &secret_ct[0], ct_len) + + delete(entries) + delete(secret_ct) + ok = true + return +} + +decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: bool) { + if !ensure_sodium() { + return + } + + if len(ciphertext) < HEADER_SIZE { + fmt.println("Error: ciphertext too short (header)") + return + } + + for i in 0 ..< 4 { + if ciphertext[i] != MAGIC_BYTES[i] { + fmt.println("Error: invalid magic bytes") + return + } + } + + offset := 4 + + sender_pk: [CRYPTO_BOX_PUBLICKEY_BYTES]u8 + for i in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES { + sender_pk[i] = ciphertext[offset + i] + } + offset += CRYPTO_BOX_PUBLICKEY_BYTES + + main_nonce: [CRYPTO_SECRETBOX_NONCE_BYTES]u8 + for i in 0 ..< CRYPTO_SECRETBOX_NONCE_BYTES { + main_nonce[i] = ciphertext[offset + i] + } + offset += CRYPTO_SECRETBOX_NONCE_BYTES + + num_recipients := u32(ciphertext[offset]) << 24 | u32(ciphertext[offset + 1]) << 16 | + u32(ciphertext[offset + 2]) << 8 | u32(ciphertext[offset + 3]) + offset += 4 + + recipients_end := offset + int(num_recipients) * RECIPIENT_ENTRY_SIZE + if recipients_end > len(ciphertext) { + fmt.println("Error: ciphertext too short (recipient data)") + return + } + + enc_sym_key: [CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES]u8 + enc_nonce: [CRYPTO_BOX_NONCE_BYTES]u8 + enc_pub: [CRYPTO_BOX_PUBLICKEY_BYTES]u8 + + x25519_pairs, pairs_ok := ssh_to_x25519(keys) + if !pairs_ok { + return + } + defer delete(x25519_pairs) + + found := false + matched_pi := 0 + for pi in 0 ..< len(x25519_pairs) { + scan_offset := offset + for ri in 0 ..< int(num_recipients) { + for i in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES { + enc_pub[i] = ciphertext[scan_offset + i] + } + scan_offset += CRYPTO_BOX_PUBLICKEY_BYTES + + match := true + for i in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES { + if enc_pub[i] != x25519_pairs[pi].Public[i] { + match = false + break + } + } + if !match { + scan_offset += CRYPTO_BOX_NONCE_BYTES + CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES + continue + } + + for i in 0 ..< CRYPTO_BOX_NONCE_BYTES { + enc_nonce[i] = ciphertext[scan_offset + i] + } + scan_offset += CRYPTO_BOX_NONCE_BYTES + + for i in 0 ..< CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES { + enc_sym_key[i] = ciphertext[scan_offset + i] + } + scan_offset += CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES + + found = true + matched_pi = pi + break + } + if found { + break + } + } + + if !found { + fmt.println("Error: no matching recipient found") + return + } + + sym_key: [CRYPTO_SECRETBOX_KEY_BYTES]u8 + rc := crypto_box_open_easy( + &sym_key[0], + &enc_sym_key[0], + CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES, + &enc_nonce[0], + &sender_pk[0], + &x25519_pairs[matched_pi].Private[0], + ) + if rc != 0 { + fmt.println("Error: failed to decrypt symmetric key") + return + } + + ct_data := ciphertext[recipients_end:] + pt_len := len(ct_data) - CRYPTO_SECRETBOX_MAC_BYTES + if pt_len < 0 { + fmt.println("Error: ciphertext too short (no encrypted data)") + return + } + + plaintext = make([]u8, pt_len) + pt_ptr: [^]u8 + if len(plaintext) > 0 { + pt_ptr = &plaintext[0] + } + rc = crypto_secretbox_open_easy(pt_ptr, &ct_data[0], u64(len(ct_data)), &main_nonce[0], &sym_key[0]) + if rc != 0 { + fmt.println("Error: symmetric decryption failed") + delete(plaintext) + return + } + + ok = true + return +} diff --git a/crypto_test.odin b/crypto_test.odin new file mode 100644 index 0000000..f188d58 --- /dev/null +++ b/crypto_test.odin @@ -0,0 +1,102 @@ +package main + +import "core:fmt" +import "core:testing" + +CRYPTO_TEST_KEY_DIR :: "/tmp/envr-test-keys" + +make_test_key_pair :: proc(name: string) -> SshKeyPair { + priv := fmt.tprintf("%s/%s", CRYPTO_TEST_KEY_DIR, name) + pub := fmt.tprintf("%s/%s.pub", CRYPTO_TEST_KEY_DIR, name) + return SshKeyPair{Private = priv, Public = pub} +} + +@(test) +test_encrypt_decrypt_roundtrip :: proc(t: ^testing.T) { + key := make_test_key_pair("test_ed25519") + original := []u8{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + + encrypted, enc_ok := encrypt(original, []SshKeyPair{key}) + testing.expect(t, enc_ok, "encryption should succeed") + testing.expect(t, len(encrypted) > 0, "ciphertext should not be empty") + defer delete(encrypted) + + decrypted, dec_ok := decrypt(encrypted, []SshKeyPair{key}) + testing.expect(t, dec_ok, "decryption should succeed") + defer delete(decrypted) + + testing.expect(t, len(decrypted) == len(original), fmt.tprintf("expected %d bytes, got %d", len(original), len(decrypted))) + for i in 0 ..< len(original) { + testing.expect(t, decrypted[i] == original[i], fmt.tprintf("byte mismatch at index %d", i)) + } +} + +@(test) +test_encrypt_decrypt_multi_recipient :: proc(t: ^testing.T) { + key1 := make_test_key_pair("test_ed25519") + key2 := make_test_key_pair("test_ed25519_second") + original := []u8{42, 43, 44, 45} + + encrypted, enc_ok := encrypt(original, []SshKeyPair{key1, key2}) + testing.expect(t, enc_ok, "encryption with 2 keys should succeed") + defer delete(encrypted) + + decrypted1, dec1_ok := decrypt(encrypted, []SshKeyPair{key1}) + testing.expect(t, dec1_ok, "decryption with key1 should succeed") + defer delete(decrypted1) + + decrypted2, dec2_ok := decrypt(encrypted, []SshKeyPair{key2}) + testing.expect(t, dec2_ok, "decryption with key2 should succeed") + defer delete(decrypted2) + + for i in 0 ..< len(original) { + testing.expect(t, decrypted1[i] == original[i], fmt.tprintf("key1: byte mismatch at %d", i)) + testing.expect(t, decrypted2[i] == original[i], fmt.tprintf("key2: byte mismatch at %d", i)) + } +} + +@(test) +test_decrypt_wrong_key_fails :: proc(t: ^testing.T) { + key1 := make_test_key_pair("test_ed25519") + key2 := make_test_key_pair("test_ed25519_second") + original := []u8{1, 2, 3} + + encrypted, enc_ok := encrypt(original, []SshKeyPair{key1}) + testing.expect(t, enc_ok, "encryption should succeed") + defer delete(encrypted) + + _, dec_ok := decrypt(encrypted, []SshKeyPair{key2}) + testing.expect(t, !dec_ok, "decryption with wrong key should fail") +} + +@(test) +test_encrypt_empty_plaintext :: proc(t: ^testing.T) { + key := make_test_key_pair("test_ed25519") + original: []u8 + + encrypted, enc_ok := encrypt(original, []SshKeyPair{key}) + testing.expect(t, enc_ok, "encryption of empty data should succeed") + defer delete(encrypted) + + decrypted, dec_ok := decrypt(encrypted, []SshKeyPair{key}) + testing.expect(t, dec_ok, "decryption should succeed") + defer delete(decrypted) + + testing.expect(t, len(decrypted) == 0, "decrypted empty data should be empty") +} + +@(test) +test_ciphertext_has_magic :: proc(t: ^testing.T) { + key := make_test_key_pair("test_ed25519") + original := []u8{1, 2, 3} + + encrypted, enc_ok := encrypt(original, []SshKeyPair{key}) + testing.expect(t, enc_ok, "encryption should succeed") + defer delete(encrypted) + + testing.expect(t, len(encrypted) >= 4, "ciphertext should have at least 4 bytes") + testing.expect(t, encrypted[0] == u8('E'), "magic byte 0") + testing.expect(t, encrypted[1] == u8('N'), "magic byte 1") + testing.expect(t, encrypted[2] == u8('V'), "magic byte 2") + testing.expect(t, encrypted[3] == u8('R'), "magic byte 3") +} diff --git a/db.odin b/db.odin index 1746f10..c806dcb 100644 --- a/db.odin +++ b/db.odin @@ -53,8 +53,8 @@ db_open :: proc() -> (Db, bool) { return Db{}, false } - age_path := data_age_path() - _, stat_err := os.stat(age_path, context.allocator) + data_path := data_encrypted_path() + _, stat_err := os.stat(data_path, context.allocator) db: ^rawptr rc := sqlite.db_open(":memory:", &db) @@ -72,7 +72,7 @@ db_open :: proc() -> (Db, bool) { } if stat_err == nil { - if !db_restore_from_age(db, cfg) { + if !db_restore_from_encrypted(db, cfg) { sqlite.db_close(db) return Db{}, false } @@ -91,8 +91,34 @@ db_close :: proc(d: ^Db) { return } - db_encrypt_file(tmp_path, d.cfg.Keys[:]) + sqlite_data, read_err := os.read_entire_file_from_path(tmp_path, context.allocator) os.remove(tmp_path) + if read_err != nil { + fmt.printf("Error reading vacuumed database: %v\n", read_err) + sqlite.db_close(d.db) + return + } + + encrypted, enc_ok := encrypt(sqlite_data, d.cfg.Keys[:]) + delete(sqlite_data) + if !enc_ok { + fmt.println("Error: encryption failed") + sqlite.db_close(d.db) + return + } + + data_path := data_encrypted_path() + envr_d := envr_dir() + os.mkdir_all(envr_d) + + write_err := os.write_entire_file(data_path, encrypted) + delete(encrypted) + if write_err != nil { + fmt.printf("Error writing encrypted database: %v\n", write_err) + sqlite.db_close(d.db) + return + } + d.changed = false } sqlite.db_close(d.db) @@ -158,14 +184,29 @@ db_vacuum_to_file :: proc(db: ^rawptr, path: string) -> bool { return true } -db_restore_from_age :: proc(db: ^rawptr, cfg: Config) -> bool { - tmp_path := make_temp_path() - defer os.remove(tmp_path) - - if !db_decrypt_to_file(tmp_path, cfg.Keys[:]) { +db_restore_from_encrypted :: proc(db: ^rawptr, cfg: Config) -> bool { + data_path := data_encrypted_path() + encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.temp_allocator) + if read_err != nil { + fmt.printf("Error reading encrypted database: %v\n", read_err) return false } + plaintext, dec_ok := decrypt(encrypted_data, cfg.Keys[:]) + if !dec_ok { + fmt.println("Error: decryption failed") + return false + } + defer delete(plaintext) + + tmp_path := make_temp_path() + write_err := os.write_entire_file(tmp_path, plaintext) + if write_err != nil { + fmt.printf("Error writing temp database: %v\n", write_err) + return false + } + defer os.remove(tmp_path) + if !db_attach_and_copy(db, tmp_path) { return false } @@ -173,93 +214,6 @@ db_restore_from_age :: proc(db: ^rawptr, cfg: Config) -> bool { return true } -db_decrypt_to_file :: proc(tmp_path: string, keys: []SshKeyPair) -> bool { - age_path := data_age_path() - - args := make([dynamic]string) - append(&args, "age") - append(&args, "--decrypt") - append(&args, "-o") - append(&args, tmp_path) - for key in keys { - append(&args, "-i") - append(&args, key.Private) - } - append(&args, age_path) - - desc := os.Process_Desc { - command = args[:], - stdout = os.stderr, - stderr = os.stderr, - } - - p, err := os.process_start(desc) - if err != nil { - fmt.printf("Error running age decrypt: %v\n", err) - return false - } - - state, wait_err := os.process_wait(p) - if wait_err != nil { - fmt.printf("Error waiting for age: %v\n", wait_err) - return false - } - if state.exit_code != 0 { - fmt.println("Error: age decryption failed") - return false - } - return true -} - -db_encrypt_file :: proc(tmp_path: string, keys: []SshKeyPair) -> bool { - age_path := data_age_path() - envr_d := envr_dir() - os.mkdir_all(envr_d) - - args := make([dynamic]string) - append(&args, "age") - append(&args, "--encrypt") - for key in keys { - append(&args, "-r") - pub_data, pub_err := os.read_entire_file_from_path(key.Public, context.allocator) - if pub_err != nil { - fmt.printf("Error reading public key: %s\n", key.Public) - return false - } - pub_str := string(pub_data) - if strings.has_suffix(pub_str, "\n") { - pub_str = pub_str[:len(pub_str) - 1] - } - append(&args, pub_str) - } - append(&args, "-o") - append(&args, age_path) - append(&args, tmp_path) - - desc := os.Process_Desc { - command = args[:], - stdout = os.stderr, - stderr = os.stderr, - } - - p, err := os.process_start(desc) - if err != nil { - fmt.printf("Error running age encrypt: %v\n", err) - return false - } - - state, wait_err := os.process_wait(p) - if wait_err != nil { - fmt.printf("Error waiting for age: %v\n", wait_err) - return false - } - if state.exit_code != 0 { - fmt.println("Error: age encryption failed") - return false - } - return true -} - db_attach_and_copy :: proc(mem_db: ^rawptr, src_path: string) -> bool { b: strings.Builder strings.builder_init(&b) diff --git a/db_integration_test.odin b/db_integration_test.odin new file mode 100644 index 0000000..3b63647 --- /dev/null +++ b/db_integration_test.odin @@ -0,0 +1,329 @@ +package main + +import "core:encoding/json" +import "core:fmt" +import "core:os" +import "core:path/filepath" +import "core:strings" +import "core:testing" + +import "sqlite" + +FIXTURES :: "/home/spencer/github.com/envr-zig/fixtures" + +fixture_key :: proc() -> SshKeyPair { + priv, _ := strings.concatenate([]string{FIXTURES, "/insecure-test-key"}, context.allocator) + pub, _ := strings.concatenate([]string{FIXTURES, "/insecure-test-key.pub"}, context.allocator) + return SshKeyPair{Private = priv, Public = pub} +} + +fixture_db_path :: proc() -> string { + p, _ := strings.concatenate([]string{FIXTURES, "/single-file.db"}, context.allocator) + return p +} + +fixture_config :: proc() -> Config { + cfg := Config{ + Keys = make([dynamic]SshKeyPair, 0, 1), + } + append(&cfg.Keys, fixture_key()) + return cfg +} + +@(test) +test_encrypt_decrypt_sqlite_roundtrip :: proc(t: ^testing.T) { + cfg := fixture_config() + defer { + delete(cfg.Keys) + } + key := cfg.Keys[0] + + db_path := fixture_db_path() + sqlite_data, read_err := os.read_entire_file_from_path(db_path, context.allocator) + testing.expectf(t, read_err == nil, "failed to read fixture db: %v", read_err) + if read_err != nil { + return + } + defer delete(sqlite_data) + + encrypted, enc_ok := encrypt(sqlite_data, cfg.Keys[:]) + testing.expect(t, enc_ok, "encryption should succeed") + if !enc_ok { + return + } + defer delete(encrypted) + + testing.expect(t, len(encrypted) >= HEADER_SIZE, "ciphertext should have header") + testing.expect(t, encrypted[0] == u8('E'), "magic byte 0") + testing.expect(t, encrypted[1] == u8('N'), "magic byte 1") + testing.expect(t, encrypted[2] == u8('V'), "magic byte 2") + testing.expect(t, encrypted[3] == u8('R'), "magic byte 3") + + plaintext, dec_ok := decrypt(encrypted, cfg.Keys[:]) + testing.expect(t, dec_ok, "decryption should succeed") + if !dec_ok { + return + } + defer delete(plaintext) + + testing.expectf( + t, + len(plaintext) == len(sqlite_data), + "round-trip size mismatch: expected %d, got %d", + len(sqlite_data), + len(plaintext), + ) + + match := true + for i in 0 ..< len(sqlite_data) { + if plaintext[i] != sqlite_data[i] { + match = false + break + } + } + testing.expect(t, match, "decrypted data should match original") +} + +@(test) +test_encrypt_write_read_decrypt :: proc(t: ^testing.T) { + cfg := fixture_config() + defer { + delete(cfg.Keys) + } + + db_path := fixture_db_path() + sqlite_data, read_err := os.read_entire_file_from_path(db_path, context.allocator) + testing.expectf(t, read_err == nil, "failed to read fixture db: %v", read_err) + if read_err != nil { + return + } + defer delete(sqlite_data) + + encrypted, enc_ok := encrypt(sqlite_data, cfg.Keys[:]) + testing.expect(t, enc_ok, "encryption should succeed") + if !enc_ok { + return + } + defer delete(encrypted) + + tmp_enc_path := fmt.tprintf("/tmp/envr-test-ewrd-%d.envr", os.get_pid()) + write_err := os.write_entire_file(tmp_enc_path, encrypted) + testing.expectf(t, write_err == nil, "failed to write encrypted file: %v", write_err) + if write_err != nil { + return + } + defer os.remove(tmp_enc_path) + + read_back, rb_err := os.read_entire_file_from_path(tmp_enc_path, context.allocator) + testing.expectf(t, rb_err == nil, "failed to read back encrypted file: %v", rb_err) + if rb_err != nil { + return + } + defer delete(read_back) + + plaintext, dec_ok := decrypt(read_back, cfg.Keys[:]) + testing.expect(t, dec_ok, "decryption after write/read should succeed") + if !dec_ok { + return + } + defer delete(plaintext) + + testing.expect(t, len(plaintext) == len(sqlite_data), "size mismatch after file round-trip") +} + +@(test) +test_decrypt_then_attach_sqlite :: proc(t: ^testing.T) { + cfg := fixture_config() + defer { + delete(cfg.Keys) + } + + db_path := fixture_db_path() + sqlite_data, read_err := os.read_entire_file_from_path(db_path, context.allocator) + testing.expectf(t, read_err == nil, "failed to read fixture db: %v", read_err) + if read_err != nil { + return + } + defer delete(sqlite_data) + + encrypted, enc_ok := encrypt(sqlite_data, cfg.Keys[:]) + testing.expect(t, enc_ok, "encryption should succeed") + if !enc_ok { + return + } + defer delete(encrypted) + + plaintext, dec_ok := decrypt(encrypted, cfg.Keys[:]) + testing.expect(t, dec_ok, "decryption should succeed") + if !dec_ok { + return + } + defer delete(plaintext) + + tmp_db_path := fmt.tprintf("/tmp/envr-test-attach-%d.db", os.get_pid()) + write_err := os.write_entire_file(tmp_db_path, plaintext) + testing.expectf(t, write_err == nil, "failed to write temp db: %v", write_err) + if write_err != nil { + return + } + defer os.remove(tmp_db_path) + + mem_db: ^rawptr + rc := sqlite.db_open(":memory:", &mem_db) + testing.expectf(t, rc == sqlite.OK, "failed to open in-memory db") + if rc != sqlite.OK { + return + } + defer sqlite.db_close(mem_db) + + create_sql := "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)" + rc = sqlite.db_exec(mem_db, string_to_cstring(create_sql), nil, nil, nil) + testing.expect(t, rc == sqlite.OK, "failed to create table") + + attach_ok := db_attach_and_copy(mem_db, tmp_db_path) + testing.expect(t, attach_ok, "failed to attach and copy") + + sql := "SELECT path FROM envr_env_files" + stmt: ^rawptr + rc = sqlite.prepare_v2(mem_db, string_to_cstring(sql), -1, &stmt, nil) + testing.expect(t, rc == sqlite.OK, "prepare failed") + if rc != sqlite.OK { + return + } + defer sqlite.finalize(stmt) + + rc = sqlite.step(stmt) + testing.expect(t, rc == sqlite.ROW, "expected at least one row") + if rc == sqlite.ROW { + path := cstring_to_string(sqlite.column_text(stmt, 0)) + testing.expect(t, len(path) > 0, "path should not be empty") + } +} + +@(test) +test_full_db_cycle :: proc(t: ^testing.T) { + cfg := fixture_config() + defer { + delete(cfg.Keys) + } + + db_path := fixture_db_path() + original_data, read_err := os.read_entire_file_from_path(db_path, context.allocator) + testing.expectf(t, read_err == nil, "failed to read fixture db: %v", read_err) + if read_err != nil { + return + } + defer delete(original_data) + + encrypted, enc_ok := encrypt(original_data, cfg.Keys[:]) + testing.expect(t, enc_ok, "first encryption should succeed") + if !enc_ok { + return + } + defer delete(encrypted) + + envr_dir_path := fmt.tprintf("/tmp/envr-test-cycle-%d/.envr", os.get_pid()) + os.mkdir_all(envr_dir_path) + + data_path, _ := filepath.join([]string{envr_dir_path, "data.envr"}) + write_err := os.write_entire_file(data_path, encrypted) + testing.expectf(t, write_err == nil, "failed to write data.envr: %v", write_err) + if write_err != nil { + return + } + + read_back, rb_err := os.read_entire_file_from_path(data_path, context.allocator) + testing.expectf(t, rb_err == nil, "failed to read data.envr: %v", rb_err) + if rb_err != nil { + return + } + defer delete(read_back) + + plaintext, dec_ok := decrypt(read_back, cfg.Keys[:]) + testing.expect(t, dec_ok, "decryption should succeed") + if !dec_ok { + return + } + defer delete(plaintext) + + encrypted2, enc2_ok := encrypt(plaintext, cfg.Keys[:]) + testing.expect(t, enc2_ok, "re-encryption should succeed") + if !enc2_ok { + return + } + defer delete(encrypted2) + + plaintext2, dec2_ok := decrypt(encrypted2, cfg.Keys[:]) + testing.expect(t, dec2_ok, "second decryption should succeed") + if !dec2_ok { + return + } + defer delete(plaintext2) + + testing.expect( + t, + len(plaintext2) == len(original_data), + fmt.tprintf("double round-trip size mismatch: expected %d, got %d", len(original_data), len(plaintext2)), + ) + + os.remove(data_path) + os.remove(envr_dir_path) + home := filepath.dir(filepath.dir(envr_dir_path)) + os.remove(home) +} + +@(test) +test_ssh_key_parse_from_fixtures :: proc(t: ^testing.T) { + key := fixture_key() + + priv_kp, priv_ok := parse_ssh_private_key(key.Private) + testing.expect(t, priv_ok, "should parse private key from fixtures") + if !priv_ok { + return + } + + pub_key, pub_ok := parse_ssh_public_key(key.Public) + testing.expect(t, pub_ok, "should parse public key from fixtures") + if !pub_ok { + return + } + + for i in 0 ..< 32 { + testing.expectf( + t, + priv_kp.Public[i] == pub_key[i], + "public key mismatch at byte %d", + i, + ) + } + + x25519_pairs, x_ok := ssh_to_x25519([]SshKeyPair{key}) + testing.expect(t, x_ok, "ssh_to_x25519 should succeed") + if !x_ok { + return + } + defer delete(x25519_pairs) + + testing.expect(t, len(x25519_pairs) == 1, "should have 1 x25519 keypair") +} + +@(test) +test_config_load_with_fixture_key :: proc(t: ^testing.T) { + cfg := fixture_config() + defer { + delete(cfg.Keys) + } + + testing.expect(t, len(cfg.Keys) == 1, "should have 1 key") + + key := cfg.Keys[0] + + testing.expectf(t, len(key.Private) > 0, "private key path should not be empty") + testing.expectf(t, len(key.Public) > 0, "public key path should not be empty") + + priv_kp, priv_ok := parse_ssh_private_key(key.Private) + testing.expect(t, priv_ok, "should parse private key using config paths") + if !priv_ok { + fmt.printf(" private key path was: '%s'\n", key.Private) + } +} diff --git a/features.odin b/features.odin index a6334c3..72e2102 100644 --- a/features.odin +++ b/features.odin @@ -8,7 +8,6 @@ import "core:strings" Feature :: enum { Git, Fd, - Age, } AvailableFeatures :: bit_set[Feature] @@ -31,9 +30,6 @@ check_features :: proc() -> AvailableFeatures { if find_binary(paths, "fd") != "" { feats += {.Fd} } - if find_binary(paths, "age") != "" { - feats += {.Age} - } return feats } diff --git a/flake.nix b/flake.nix index 1194c53..29c3f90 100644 --- a/flake.nix +++ b/flake.nix @@ -98,6 +98,7 @@ cobra-cli age + libsodium sqlite unstable.odin unstable.ols diff --git a/main.odin.bak b/main.odin.bak new file mode 100644 index 0000000..afdfbcb --- /dev/null +++ b/main.odin.bak @@ -0,0 +1,44 @@ +package main + +import "core:fmt" +import "core:os" + +main :: proc() { + cmd, ok := parse_args() + if !ok { + return + } + + switch cmd.name { + case "init": + cmd_init(&cmd) + case "version": + cmd_version(&cmd) + case "deps": + cmd_deps(&cmd) + case "list": + cmd_list(&cmd) + case "backup", "add": + cmd_backup(&cmd) + case "remove": + cmd_remove(&cmd) + case "restore": + cmd_restore(&cmd) + case "edit-config": + cmd_edit_config(&cmd) + case "check": + cmd_check(&cmd) + case "scan": + cmd_scan(&cmd) + case "sync": + cmd_sync(&cmd) + case "nushell-completion": + cmd_nushell_completion(&cmd) + case: + fmt.printf("Unknown command: %s\n", cmd.name) + print_usage() + os.exit(1) + } +} + + diff --git a/sodium.odin b/sodium.odin new file mode 100644 index 0000000..8a4ef25 --- /dev/null +++ b/sodium.odin @@ -0,0 +1,30 @@ +package main + +import "core:c" + +foreign import libsodium "system:sodium" + +CRYPTO_BOX_PUBLICKEY_BYTES :: 32 +CRYPTO_BOX_SECRETKEY_BYTES :: 32 +CRYPTO_BOX_NONCE_BYTES :: 24 +CRYPTO_BOX_MAC_BYTES :: 16 + +CRYPTO_SECRETBOX_KEY_BYTES :: 32 +CRYPTO_SECRETBOX_NONCE_BYTES :: 24 +CRYPTO_SECRETBOX_MAC_BYTES :: 16 + +CRYPTO_SIGN_PUBLICKEY_BYTES :: 32 +CRYPTO_SIGN_SECRETKEY_BYTES :: 64 + +@(default_calling_convention = "c") +foreign libsodium { + sodium_init :: proc() -> c.int --- + crypto_box_keypair :: proc(pk: [^]u8, sk: [^]u8) -> c.int --- + crypto_box_easy :: proc(ciphertext: [^]u8, plaintext: [^]u8, mlen: c.ulong, nonce: [^]u8, pk: [^]u8, sk: [^]u8) -> c.int --- + crypto_box_open_easy :: proc(plaintext: [^]u8, ciphertext: [^]u8, clen: c.ulong, nonce: [^]u8, pk: [^]u8, sk: [^]u8) -> c.int --- + crypto_secretbox_easy :: proc(ciphertext: [^]u8, plaintext: [^]u8, mlen: c.ulong, nonce: [^]u8, key: [^]u8) -> c.int --- + crypto_secretbox_open_easy :: proc(plaintext: [^]u8, ciphertext: [^]u8, clen: c.ulong, nonce: [^]u8, key: [^]u8) -> c.int --- + crypto_sign_ed25519_pk_to_curve25519 :: proc(curve25519_pk: [^]u8, ed25519_pk: [^]u8) -> c.int --- + crypto_sign_ed25519_sk_to_curve25519 :: proc(curve25519_sk: [^]u8, ed25519_sk: [^]u8) -> c.int --- + randombytes_buf :: proc(buf: [^]u8, size: c.ulong) --- +} diff --git a/ssh.odin b/ssh.odin new file mode 100644 index 0000000..6371b1d --- /dev/null +++ b/ssh.odin @@ -0,0 +1,255 @@ +package main + +import "core:encoding/base64" +import "core:fmt" +import "core:os" +import "core:strings" + +SSH_ED25519 :: "ssh-ed25519" + +Ed25519Keypair :: struct { + Public: [32]u8, + Private: [32]u8, +} + +read_wire_string :: proc(data: []u8, offset: ^int) -> (s: string, ok: bool) { + if offset^ + 4 > len(data) { + return + } + length := u32(data[offset^]) << 24 | u32(data[offset^ + 1]) << 16 | + u32(data[offset^ + 2]) << 8 | u32(data[offset^ + 3]) + offset^ += 4 + + if offset^ + int(length) > len(data) { + return + } + + s = string(data[offset^ : offset^ + int(length)]) + offset^ += int(length) + ok = true + return +} + +parse_ssh_public_key :: proc(pub_path: string) -> (pub: [32]u8, ok: bool) { + data, err := os.read_entire_file_from_path(pub_path, context.temp_allocator) + if err != nil { + return + } + + text := strings.trim_right(string(data), "\n") + parts := strings.split(text, " ", context.temp_allocator) + if len(parts) < 2 { + return + } + if parts[0] != SSH_ED25519 { + return + } + + decoded, decode_err := base64.decode(parts[1], allocator = context.temp_allocator) + if decode_err != nil || len(decoded) < 51 { + return + } + + offset := 0 + key_type, type_ok := read_wire_string(decoded, &offset) + if !type_ok || key_type != SSH_ED25519 { + return + } + + pk_data, pk_ok := read_wire_string(decoded, &offset) + if !pk_ok || len(pk_data) != 32 { + return + } + + for i in 0 ..< 32 { + pub[i] = pk_data[i] + } + + ok = true + return +} + +parse_ssh_private_key :: proc(priv_path: string) -> (kp: Ed25519Keypair, ok: bool) { + data, err := os.read_entire_file_from_path(priv_path, context.temp_allocator) + if err != nil { + return + } + + text := string(data) + lines := strings.split(text, "\n", context.temp_allocator) + + b: strings.Builder + strings.builder_init(&b, context.temp_allocator) + defer strings.builder_destroy(&b) + + in_block := false + for line in lines { + trimmed := strings.trim_space(line) + if trimmed == "-----BEGIN OPENSSH PRIVATE KEY-----" { + in_block = true + continue + } + if trimmed == "-----END OPENSSH PRIVATE KEY-----" { + break + } + if in_block && len(trimmed) > 0 { + fmt.sbprintf(&b, "%s", trimmed) + } + } + + b64_str := strings.to_string(b) + decoded, decode_err := base64.decode(b64_str, allocator = context.temp_allocator) + if decode_err != nil { + return + } + + magic := "openssh-key-v1\x00" + if len(decoded) < len(magic) { + return + } + for i in 0 ..< len(magic) { + if decoded[i] != u8(magic[i]) { + return + } + } + + offset := len(magic) + + ciphername, cipher_ok := read_wire_string(decoded, &offset) + if !cipher_ok || ciphername != "none" { + return + } + + kdfname, kdf_ok := read_wire_string(decoded, &offset) + if !kdf_ok || kdfname != "none" { + return + } + + _, opts_ok := read_wire_string(decoded, &offset) + if !opts_ok { + return + } + + if offset + 4 > len(decoded) { + return + } + num_keys := u32(decoded[offset]) << 24 | u32(decoded[offset + 1]) << 16 | + u32(decoded[offset + 2]) << 8 | u32(decoded[offset + 3]) + offset += 4 + + if num_keys != 1 { + return + } + + _, pub_blob_ok := read_wire_string(decoded, &offset) + if !pub_blob_ok { + return + } + + priv_blob, priv_blob_ok := read_wire_string(decoded, &offset) + if !priv_blob_ok { + return + } + + inner_offset := 0 + if inner_offset + 8 > len(priv_blob) { + return + } + check1 := u32(priv_blob[inner_offset]) << 24 | u32(priv_blob[inner_offset + 1]) << 16 | + u32(priv_blob[inner_offset + 2]) << 8 | u32(priv_blob[inner_offset + 3]) + inner_offset += 4 + check2 := u32(priv_blob[inner_offset]) << 24 | u32(priv_blob[inner_offset + 1]) << 16 | + u32(priv_blob[inner_offset + 2]) << 8 | u32(priv_blob[inner_offset + 3]) + inner_offset += 4 + + if check1 != check2 { + return + } + + priv_type, type_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset) + if !type_ok || priv_type != SSH_ED25519 { + return + } + + pub_wire, pub_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset) + if !pub_ok || len(pub_wire) != 32 { + return + } + for i in 0 ..< 32 { + kp.Public[i] = pub_wire[i] + } + + priv_wire, priv_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset) + if !priv_ok || len(priv_wire) != 64 { + return + } + for i in 0 ..< 32 { + kp.Private[i] = priv_wire[i] + } + + ok = true + return +} + +is_ed25519_key :: proc(priv_path: string) -> bool { + pub_path, _ := strings.concatenate([]string{priv_path, ".pub"}, context.temp_allocator) + _, ok := parse_ssh_public_key(pub_path) + return ok +} + +is_encrypted_key :: proc(priv_path: string) -> bool { + data, err := os.read_entire_file_from_path(priv_path, context.temp_allocator) + if err != nil { + return true + } + + if !strings.contains(string(data), "BEGIN OPENSSH PRIVATE KEY") { + return true + } + + text := string(data) + lines := strings.split(text, "\n", context.temp_allocator) + + b2: strings.Builder + strings.builder_init(&b2, context.temp_allocator) + defer strings.builder_destroy(&b2) + + in_block := false + for line in lines { + trimmed := strings.trim_space(line) + if trimmed == "-----BEGIN OPENSSH PRIVATE KEY-----" { + in_block = true + continue + } + if trimmed == "-----END OPENSSH PRIVATE KEY-----" { + break + } + if in_block && len(trimmed) > 0 { + fmt.sbprintf(&b2, "%s", trimmed) + } + } + + b64_str := strings.to_string(b2) + decoded, decode_err := base64.decode(b64_str, allocator = context.temp_allocator) + if decode_err != nil { + return true + } + + magic := "openssh-key-v1\x00" + if len(decoded) < len(magic) { + return true + } + for i in 0 ..< len(magic) { + if decoded[i] != u8(magic[i]) { + return true + } + } + + offset := len(magic) + ciphername, cipher_ok := read_wire_string(decoded, &offset) + if !cipher_ok { + return true + } + + return ciphername != "none" +} diff --git a/ssh_test.odin b/ssh_test.odin new file mode 100644 index 0000000..b07ddb7 --- /dev/null +++ b/ssh_test.odin @@ -0,0 +1,69 @@ +package main + +import "core:fmt" +import "core:os" +import "core:strings" +import "core:testing" + +TEST_KEY_DIR :: "/tmp/envr-test-keys" + +@(test) +test_parse_ed25519_public_key :: proc(t: ^testing.T) { + pub, ok := parse_ssh_public_key(TEST_KEY_DIR + "/test_ed25519.pub") + testing.expect(t, ok, "expected ed25519 public key to parse") + testing.expect(t, pub != [32]u8{}, fmt.tprintf("expected non-zero public key")) +} + +@(test) +test_parse_ed25519_private_key :: proc(t: ^testing.T) { + kp, ok := parse_ssh_private_key(TEST_KEY_DIR + "/test_ed25519") + testing.expect(t, ok, "expected ed25519 private key to parse") + testing.expect(t, kp.Public != [32]u8{}, "expected non-zero public key") + testing.expect(t, kp.Private != [32]u8{}, "expected non-zero private key") +} + +@(test) +test_parse_rsa_public_key_fails :: proc(t: ^testing.T) { + _, ok := parse_ssh_public_key(TEST_KEY_DIR + "/test_rsa.pub") + testing.expect(t, !ok, "expected RSA key parsing to fail") +} + +@(test) +test_is_ed25519_key_true :: proc(t: ^testing.T) { + testing.expect(t, is_ed25519_key(TEST_KEY_DIR + "/test_ed25519")) +} + +@(test) +test_is_ed25519_key_false_for_rsa :: proc(t: ^testing.T) { + testing.expect(t, !is_ed25519_key(TEST_KEY_DIR + "/test_rsa")) +} + +@(test) +test_private_key_pub_matches_public_key :: proc(t: ^testing.T) { + pub_from_pub, pub_ok := parse_ssh_public_key(TEST_KEY_DIR + "/test_ed25519.pub") + testing.expect(t, pub_ok, "expected public key to parse") + + kp, priv_ok := parse_ssh_private_key(TEST_KEY_DIR + "/test_ed25519") + testing.expect(t, priv_ok, "expected private key to parse") + + testing.expect( + t, + pub_from_pub == kp.Public, + fmt.tprintf("public key mismatch:\n from .pub: %v\n from priv: %v", pub_from_pub, kp.Public), + ) +} + +@(test) +test_read_wire_string :: proc(t: ^testing.T) { + data := []u8{0, 0, 0, 5, u8('h'), u8('e'), u8('l'), u8('l'), u8('o'), 0, 0, 0, 0} + offset := 0 + + s, ok := read_wire_string(data, &offset) + testing.expect(t, ok, "expected read_wire_string to succeed") + testing.expect(t, s == "hello", fmt.tprintf("expected 'hello', got %q", s)) + testing.expect(t, offset == 9, fmt.tprintf("expected offset 9, got %d", offset)) + + s2, ok2 := read_wire_string(data, &offset) + testing.expect(t, ok2, "expected second read to succeed") + testing.expect(t, s2 == "", "expected empty string") +}