mirror of
https://github.com/sbrow/envr.git
synced 2026-06-27 10:38:33 -04:00
feat: Switched from age to libsodium.
This means, fewer dependencies, a smaller binary, and more secure data. BREAKING CHANGE: The encryption format of databases has changed. Age encryption is no longer supported, and no automatic migration path was implemented.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,8 @@
|
|||||||
# dev env
|
# dev env
|
||||||
.direnv
|
.direnv
|
||||||
|
|
||||||
|
list.json
|
||||||
|
|
||||||
# docs
|
# docs
|
||||||
man
|
man
|
||||||
|
|
||||||
|
|||||||
6
TODOS.md
6
TODOS.md
@@ -4,14 +4,14 @@ Note: These todos can wait until all the subcommands have been ported.
|
|||||||
|
|
||||||
## HIGH
|
## 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.
|
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"`).
|
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.
|
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
|
## MEDIUM
|
||||||
|
|
||||||
4. **db.odin:29-35** — `make_temp_path` never calls `strings.builder_destroy`. Leaks builder buffer every call.
|
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
|
## 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.
|
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.
|
16. **db.odin:352-353** — `hex.encode` error ignored. `string(hex_bytes)` aliases the byte slice.
|
||||||
|
|||||||
@@ -20,11 +20,5 @@ cmd_deps :: proc(cmd: ^Command) {
|
|||||||
append(&rows, []string{"fd", "\u2717 Missing"})
|
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[:])
|
render_table(headers, rows[:])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ cmd_init :: proc(cmd: ^Command) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(keys) == 0 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,52 +6,56 @@ import "core:path/filepath"
|
|||||||
import "core:strings"
|
import "core:strings"
|
||||||
|
|
||||||
ListEntry :: struct {
|
ListEntry :: struct {
|
||||||
Directory: string `json:"directory"`,
|
Directory: string `json:"directory"`,
|
||||||
Path: string `json:"path"`,
|
Path: string `json:"path"`,
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_list :: proc(cmd: ^Command) {
|
cmd_list :: proc(cmd: ^Command) {
|
||||||
db, db_ok := db_open()
|
db, db_ok := db_open()
|
||||||
if !db_ok {
|
if !db_ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer db_close(&db)
|
defer db_close(&db)
|
||||||
|
|
||||||
rows, list_ok := db_list(&db)
|
rows, list_ok := db_list(&db)
|
||||||
if !list_ok {
|
if !list_ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer delete(rows)
|
defer delete(rows)
|
||||||
|
|
||||||
if is_tty() {
|
if is_tty() {
|
||||||
headers := []string{"Directory", "Path"}
|
headers := []string{"Directory", "Path"}
|
||||||
table_rows := make([dynamic][]string, 0, len(rows))
|
table_rows := make([dynamic][]string, 0, len(rows), context.temp_allocator)
|
||||||
|
|
||||||
for row in rows {
|
for row in rows {
|
||||||
dir_str := strings.concatenate({row.Dir, "/"})
|
dir_str := strings.concatenate({row.Dir, "/"}, context.temp_allocator)
|
||||||
filename := filepath.base(row.Path)
|
filename := filepath.base(row.Path)
|
||||||
row_slice := make([]string, 2)
|
row_slice := make([]string, 2)
|
||||||
row_slice[0] = dir_str
|
row_slice[0] = dir_str
|
||||||
row_slice[1] = filename
|
row_slice[1] = filename
|
||||||
append(&table_rows, row_slice)
|
append(&table_rows, row_slice)
|
||||||
}
|
}
|
||||||
|
|
||||||
render_table(headers, table_rows[:])
|
render_table(headers, table_rows[:])
|
||||||
} else {
|
} else {
|
||||||
entries: [dynamic]ListEntry
|
entries: [dynamic]ListEntry
|
||||||
for row in rows {
|
for row in rows {
|
||||||
filename := filepath.base(row.Path)
|
filename := filepath.base(row.Path)
|
||||||
append(&entries, ListEntry{
|
append(
|
||||||
Directory = strings.concatenate({row.Dir, "/"}),
|
&entries,
|
||||||
Path = filename,
|
ListEntry {
|
||||||
})
|
Directory = strings.concatenate({row.Dir, "/"}, context.temp_allocator),
|
||||||
}
|
Path = filename,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
data, marshal_err := json.marshal(entries[:])
|
data, marshal_err := json.marshal(entries[:])
|
||||||
if marshal_err != nil {
|
if marshal_err != nil {
|
||||||
fmt.printf("Error marshaling JSON: %v\n", marshal_err)
|
fmt.printf("Error marshaling JSON: %v\n", marshal_err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.println(string(data))
|
fmt.println(string(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ envr_dir :: proc() -> string {
|
|||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
data_age_path :: proc() -> string {
|
data_encrypted_path :: proc() -> string {
|
||||||
dir := envr_dir()
|
dir := envr_dir()
|
||||||
path, _ := filepath.join([]string{dir, "data.age"})
|
path, _ := filepath.join([]string{dir, "data.envr"})
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +103,9 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
full_path, _ := filepath.join([]string{ssh_dir, name})
|
full_path, _ := filepath.join([]string{ssh_dir, name})
|
||||||
|
if !is_ed25519_key(full_path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
append(&keys, full_path)
|
append(&keys, full_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
313
crypto.odin
Normal file
313
crypto.odin
Normal file
@@ -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
|
||||||
|
}
|
||||||
102
crypto_test.odin
Normal file
102
crypto_test.odin
Normal file
@@ -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")
|
||||||
|
}
|
||||||
146
db.odin
146
db.odin
@@ -53,8 +53,8 @@ db_open :: proc() -> (Db, bool) {
|
|||||||
return Db{}, false
|
return Db{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
age_path := data_age_path()
|
data_path := data_encrypted_path()
|
||||||
_, stat_err := os.stat(age_path, context.allocator)
|
_, stat_err := os.stat(data_path, context.allocator)
|
||||||
|
|
||||||
db: ^rawptr
|
db: ^rawptr
|
||||||
rc := sqlite.db_open(":memory:", &db)
|
rc := sqlite.db_open(":memory:", &db)
|
||||||
@@ -72,7 +72,7 @@ db_open :: proc() -> (Db, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if stat_err == nil {
|
if stat_err == nil {
|
||||||
if !db_restore_from_age(db, cfg) {
|
if !db_restore_from_encrypted(db, cfg) {
|
||||||
sqlite.db_close(db)
|
sqlite.db_close(db)
|
||||||
return Db{}, false
|
return Db{}, false
|
||||||
}
|
}
|
||||||
@@ -91,8 +91,34 @@ db_close :: proc(d: ^Db) {
|
|||||||
return
|
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)
|
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
|
d.changed = false
|
||||||
}
|
}
|
||||||
sqlite.db_close(d.db)
|
sqlite.db_close(d.db)
|
||||||
@@ -158,14 +184,29 @@ db_vacuum_to_file :: proc(db: ^rawptr, path: string) -> bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
db_restore_from_age :: proc(db: ^rawptr, cfg: Config) -> bool {
|
db_restore_from_encrypted :: proc(db: ^rawptr, cfg: Config) -> bool {
|
||||||
tmp_path := make_temp_path()
|
data_path := data_encrypted_path()
|
||||||
defer os.remove(tmp_path)
|
encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.temp_allocator)
|
||||||
|
if read_err != nil {
|
||||||
if !db_decrypt_to_file(tmp_path, cfg.Keys[:]) {
|
fmt.printf("Error reading encrypted database: %v\n", read_err)
|
||||||
return false
|
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) {
|
if !db_attach_and_copy(db, tmp_path) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -173,93 +214,6 @@ db_restore_from_age :: proc(db: ^rawptr, cfg: Config) -> bool {
|
|||||||
return true
|
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 {
|
db_attach_and_copy :: proc(mem_db: ^rawptr, src_path: string) -> bool {
|
||||||
b: strings.Builder
|
b: strings.Builder
|
||||||
strings.builder_init(&b)
|
strings.builder_init(&b)
|
||||||
|
|||||||
329
db_integration_test.odin
Normal file
329
db_integration_test.odin
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import "core:strings"
|
|||||||
Feature :: enum {
|
Feature :: enum {
|
||||||
Git,
|
Git,
|
||||||
Fd,
|
Fd,
|
||||||
Age,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AvailableFeatures :: bit_set[Feature]
|
AvailableFeatures :: bit_set[Feature]
|
||||||
@@ -31,9 +30,6 @@ check_features :: proc() -> AvailableFeatures {
|
|||||||
if find_binary(paths, "fd") != "" {
|
if find_binary(paths, "fd") != "" {
|
||||||
feats += {.Fd}
|
feats += {.Fd}
|
||||||
}
|
}
|
||||||
if find_binary(paths, "age") != "" {
|
|
||||||
feats += {.Age}
|
|
||||||
}
|
|
||||||
|
|
||||||
return feats
|
return feats
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
cobra-cli
|
cobra-cli
|
||||||
|
|
||||||
age
|
age
|
||||||
|
libsodium
|
||||||
sqlite
|
sqlite
|
||||||
unstable.odin
|
unstable.odin
|
||||||
unstable.ols
|
unstable.ols
|
||||||
|
|||||||
44
main.odin.bak
Normal file
44
main.odin.bak
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
30
sodium.odin
Normal file
30
sodium.odin
Normal file
@@ -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) ---
|
||||||
|
}
|
||||||
255
ssh.odin
Normal file
255
ssh.odin
Normal file
@@ -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"
|
||||||
|
}
|
||||||
69
ssh_test.odin
Normal file
69
ssh_test.odin
Normal file
@@ -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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user