diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml new file mode 100644 index 0000000..bc50629 --- /dev/null +++ b/.forgejo/workflows/test.yml @@ -0,0 +1,17 @@ +on: + push: + branches: + - main + paths: + - "*.go" + workflow_dispatch: +jobs: + run-tests: + runs-on: docker + container: "golang:1.25" + steps: + - run: apt install git + # always runs on code from main + - run: git clone -b ${{ env.FORGEJO_REF_NAME }} https://remotebranch.eu/ChaoticByte/journal.git + - run: go test -v + working-directory: ./journal diff --git a/build.sh b/build.sh index d945025..0b6e755 100755 --- a/build.sh +++ b/build.sh @@ -14,7 +14,5 @@ function build() { echo Output dir: ./dist/${VERSION}/ -build linux "386" i386 build linux amd64 amd64 -build linux arm arm build linux arm64 arm64 diff --git a/data.go b/data.go index 425a119..a88b9c7 100644 --- a/data.go +++ b/data.go @@ -229,6 +229,8 @@ func OpenJournalFile(file string, password *memguard.Enclave) (*JournalFile, err } +const MaxEntrySize = uint32(4294967295) // (2^32)-1 + type EncryptedEntry struct { Timestamp uint64 // Unix time in microseconds, works until year 294246 Salt [12]byte @@ -247,6 +249,9 @@ func (e *EncryptedEntry) EtLength() uint32 { func NewEncryptedEntry(text string, password *memguard.Enclave) (*EncryptedEntry, error) { e := EncryptedEntry{} + if uint32(len(text)) > MaxEntrySize { + text = text[:MaxEntrySize] + } e.Timestamp = uint64(time.Now().UnixMicro()) ct, s, n, err := EncryptText(password, text, e.Timestamp) if err != nil { diff --git a/data_test.go b/data_test.go new file mode 100644 index 0000000..738c1cb --- /dev/null +++ b/data_test.go @@ -0,0 +1,119 @@ +// Copyright (c) 2026, Julian Müller (ChaoticByte) + +package main + +import ( + "fmt" + "math/rand" + "os" + "slices" + "strings" + "testing" + "time" + + "github.com/awnumar/memguard" +) + +const JournalTestFile = "/tmp/journal_test" + +func TestDataformat(t *testing.T) { + passwd := memguard.NewEnclave([]byte("secureTestP4ssw0rd!")) + defer memguard.Purge() + defer os.Remove(JournalTestFile) + var j *JournalFile + var err error + t.Run("CreateJournalFile", func(t *testing.T) { + // create test journal + os.Remove(JournalTestFile) // possibly remove old file + j, err = OpenJournalFile(JournalTestFile, passwd) + if err != nil { + t.Error("Could not create test journal; ", err) + } + }) + // define entries + entryTexts := []string { + "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.", + "Sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.", + "Sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.", + "Consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.", + "This is just a test", + "Another test", + "test", + "aaaa", + } + tb := strings.Builder{} + for i := range 1024*1024 { // big entry + fmt.Fprintf(&tb, "%x", i) + if err != nil { + t.Error("Unknown error when writing to string builder") + } + } + entryTexts = append(entryTexts, tb.String()) + t.Run("CreateAndAddEntries", func(t *testing.T) { + for i, txt := range entryTexts { + e, err := NewEncryptedEntry(txt, passwd) + if err != nil { + t.Errorf("Could not create entry %v! %v", i, err) + } + err = j.AddEntry(e) + if err != nil { + t.Errorf("Could not add entry %v to journal! %v", i, err) + } + j.Write() + time.Sleep(time.Duration(rand.Float64() + 1.0) * time.Second) + } + }) + t.Run("ReopenJournalFile", func(t *testing.T) { + // re-open + j.Close() + j, err = OpenJournalFile(JournalTestFile, passwd) + if err != nil { + t.Error("Could not open test journal; ", err) + } + }) + t.Run("ReadEntries", func(t *testing.T) { + es := j.GetEntries() + len_es := len(es) + lenInput := len(entryTexts) + if len_es != lenInput { + t.Errorf("Invalid number of entries! Expected %v, but got %v", lenInput, len_es) + } + slices.Sort(es) // this is important to get the right order! + for i, ts := range es { + e := j.GetEntry(ts) + if e == nil { + t.Errorf("Could not get entry %v!", ts) + } + txt, err := e.Decrypt(passwd) + if err != nil { + t.Errorf("Could not decrypt entry %v! %v", ts, err) + } + if txt != entryTexts[i] { + t.Errorf("Decrypted text of entry %v does not match input text!", ts) + } + } + }) + var removed_ts uint64 + t.Run("RemoveEntries", func(t *testing.T) { + removed_ts = j.GetEntries()[0] + err := j.DeleteEntry(removed_ts) + if err != nil { + t.Errorf("Could not delete entry %v from journal!", removed_ts) + } + }) + t.Run("ReopenJournalFile2", func(t *testing.T) { + // re-open + j.Close() + j, err = OpenJournalFile(JournalTestFile, passwd) + if err != nil { + t.Error("Could not open test journal; ", err) + } + }) + t.Run("CheckRemovedEntries", func(t *testing.T) { + for _, ts := range j.GetEntries() { + if ts == removed_ts { + t.Error("Found deleted entry!") + } + } + }) +} diff --git a/encrypt_test.go b/encrypt_test.go new file mode 100644 index 0000000..b6e8e4d --- /dev/null +++ b/encrypt_test.go @@ -0,0 +1,105 @@ +// Copyright (c) 2026, Julian Müller (ChaoticByte) + +package main + +import ( + crand "crypto/rand" + "math/rand" + "slices" + "testing" + "time" + + "github.com/awnumar/memguard" +) + +func TestKeyDerivation(t *testing.T) { + password1 := []byte("test") + password2 := make([]byte, 20) + crand.Read(password2) + salt1 := [12]byte{} + crand.Read(salt1[:]) + salt2 := [12]byte{} + crand.Read(salt2[:]) + // + key1 := derive_key(password1, salt1) + key2 := derive_key(password2, salt2) + // + if key1 == key2 { t.Error("derived key1 == key2!") } + // + key1_salt2 := derive_key(password1, salt2) + key2_salt1 := derive_key(password2, salt1) + if key1 == key1_salt2 { t.Error("derived key1 == (key1 with wrong salt)!") } + if key2 == key2_salt1 { t.Error("derived key2 == (key2 with wrong salt)!") } + // + rekey1 := derive_key(password1, salt1) + rekey2 := derive_key(password2, salt2) + if rekey1 != key1 { t.Error("kdf is non-deterministic! derived key1 != re-key1!") } + if rekey2 != key2 { t.Error("kdf is non-deterministic! derived key2 != re-key2!") } +} + +func trialWithTamperedInput(t *testing.T, what string, password *memguard.Enclave, ciphertext []byte, salt [12]byte, noncePfx [16]byte, time uint64, original string) { + clt_t, err := DecryptText(password, ciphertext, salt, noncePfx, time) + if err == nil || err.Error() != "chacha20poly1305: message authentication failed" { + t.Errorf("Could decrypt with tampered %v; message authentication not functioning properly!", what) + } else if clt_t == original { + t.Errorf("Could decrypt with tampered %v and no error given by aead.Open()! message authentication not functioning properly!", what) + } +} + +func TestCrypto(t *testing.T) { + password1 := memguard.NewEnclave([]byte("test")) + password2_bytes := make([]byte, 20) + crand.Read(password2_bytes) + password2 := memguard.NewEnclave(password2_bytes) + t1 := uint64(time.Now().UnixMicro()) + time.Sleep(time.Duration(1.0 + rand.Float64()) * time.Second) + t2 := uint64(time.Now().UnixMicro()) + cleartext := "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." + // + cit1, salt1, noncePfx1, err1 := EncryptText(password1, cleartext, t1) + if err1 != nil { t.Fatalf("Could not encrypt with password1, err: %v", err1) } + cit2, salt2, noncePfx2, err2 := EncryptText(password2, cleartext, t2) + if err2 != nil { t.Fatalf("Could not encrypt with password2, err: %v", err2) } + // + if salt1 == salt2 { + t.Error("salt1 and salt2 are the same!") + } + if noncePfx1 == noncePfx2 { + t.Error("random part of nonce 1 and 2 are the same") + } + if slices.Equal(cit1, []byte(cleartext)) { + t.Error("ciphertext1 == cleartext!") + } + if slices.Equal(cit2, []byte(cleartext)) { + t.Error("ciphertext2 == cleartext!") + } + // + clt_decrypted1, err := DecryptText(password1, cit1, salt1, noncePfx1, t1) + if err != nil { + t.Error("Could not decrypt ciphertext1 using password1!") + } + if clt_decrypted1 != cleartext { + t.Error("Decrypted ciphertext1 does not equal original ciphertext!") + } + clt_decrypted2, err := DecryptText(password2, cit2, salt2, noncePfx2, t2) + if err != nil { + t.Error("Could not decrypt ciphertext2 using password1!") + } + if clt_decrypted2 != cleartext { + t.Error("Decrypted ciphertext2 does not equal original ciphertext!") + } + // + trialWithTamperedInput(t, "wrong password", password2, cit1, salt1, noncePfx1, t1, cleartext) + trialWithTamperedInput(t, "wrong password", password1, cit2, salt2, noncePfx2, t2, cleartext) + trialWithTamperedInput(t, "tampered nonce prefix", password1, cit1, salt1, noncePfx2, t1, cleartext) + trialWithTamperedInput(t, "tampered nonce prefix", password2, cit2, salt2, noncePfx1, t2, cleartext) + // + cit1_tampered := make([]byte, len(cit1)) + copy(cit1_tampered, cit1) + if cit1_tampered[3] < 255 { + cit1_tampered[3] += 1 + } else { + cit1_tampered[3] -= 1 + } + trialWithTamperedInput(t, "tampered ciphertext", password1, cit1_tampered, salt1, noncePfx1, t1, cleartext) +} diff --git a/main.go b/main.go index be1df12..24e991c 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,3 @@ -//go:build !addtestdata -// +build !addtestdata - // Copyright (c) 2026, Julian Müller (ChaoticByte) package main diff --git a/tui.go b/tui.go index 377367d..2d6eab3 100644 --- a/tui.go +++ b/tui.go @@ -94,7 +94,8 @@ func MultiChoiceOrCommand(choices [][2]string, commands []string, prompt string, for { // read lines until a valid choice or command is entered Out(Am(AC_SET_BOLD, AC_COL_BRIGHT_YELLOW_FG), "> ", Am(AC_RESET_BOLD, AC_COL_RESET_FG)) - a, _ := Readline() + a, err := Readline() + if err == io.EOF { Nl(); continue } for i, c := range choices { if c[0] == a { return i @@ -464,33 +465,56 @@ func mainloop(passwd *memguard.Enclave) int { mode = lastMode } - Out(Am(AC_COL_GREEN_FG), - "Write your new entry. Save it by hitting Ctrl+D in an empty line.", - Am(AC_COL_RESET_FG)) - Nnl(2) + header := func () { + Out(Am(AC_COL_GREEN_FG), + "Write a new entry; ", + Am(AC_COL_RESET_FG, AC_SET_DIM), + "Save it by hitting ", Am(AC_RESET_DIM), "Ctrl+D", + Am(AC_SET_DIM), " in an empty line.\n", + "You can delete the previous line with ", + Am(AC_RESET_DIM), "dd", Am(AC_SET_DIM), + " and ", Am(AC_RESET_DIM), "Enter", Am(AC_RESET_DIM), ".") + Nnl(2) + } + + header() // read text from stdin (rune by rune) - builder := strings.Builder{} - reader := bufio.NewReader(os.Stdin) + lines := []string{} for { - r, _, err := reader.ReadRune() - if err == io.EOF { break } - builder.WriteRune(r) - if err != nil { + line, err := Readline() + if err == io.EOF { + break + } else if err != nil { handleErr(err, "Couldn't read terminal input") } + if line == "dd" { + ll := len(lines) + if ll < 1 { + lines = []string{} + } else { + lines = lines[:ll-1] + } + Out(AS_RESET, AS_CUR_HOME) + header() + for _, l := range lines { + Out(l); Nl() + } + } else { + lines = append(lines, line) + } } // Try to create new EncryptedEntry from the input text - e, err := NewEncryptedEntry(strings.Trim(builder.String(), " \n"), passwd) + e, err := NewEncryptedEntry(strings.Trim(strings.Join(lines, "\n"), " \n"), passwd) if err != nil { handleErr(err, "Error creating new entry") continue } // empty input - builder.Reset() + lines = nil err = j.AddEntry(e) if err != nil {