journal/data.go
ChaoticByte 64ab24aa8a
All checks were successful
/ run-tests (push) Successful in 9m29s
Fix misleading comment in data.go
2026-01-22 18:35:35 +01:00

351 lines
8.6 KiB
Go

package main
// Copyright (c) 2026, Julian Müller (ChaoticByte)
import (
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"io"
"os"
"slices"
"time"
"github.com/awnumar/memguard"
)
var EntryIdAlreadyExists = errors.New("There already exists an entry at this timestamp!")
var EntryNotFound = errors.New("No entry exists at this timestamp!")
var UnsupportedJournalVersion = errors.New("Unsupported journal version!")
var FilepathIsDirectory = errors.New("The given filepath points to a directory!")
var JournalClosed = errors.New("Journal already closed, can't access data.")
var UnknownFileReadErr = errors.New("Unknown file read error")
var FileModifiedExternally = errors.New("The file was modified by another process since last read/write!")
// Journal Format Version -> App Version
// 0 -> < 1.0.0
// 1 -> since 1.0.0
const JournalFormatVersion = uint8(1)
const JournalFileMode = 0o644
const JournalPos_Version = 0
const JournalPos_Entries = 1
type JournalFile struct {
Version uint8
Filepath string
entries map[uint64]EncryptedEntry
needWrite bool
closed bool
statLastModTime time.Time
}
func (j *JournalFile) GetEntries() []uint64 {
if j.closed { return []uint64{} }
es := []uint64{}
for ts := range j.entries {
if ts != 0 { // filter out reserved entry 0
es = append(es, ts)
}
}
return es
}
func (j *JournalFile) GetLatestEntry() uint64 {
// returns timestamp, or 0 if nonexistent
es := j.GetEntries()
if len(es) == 0 { return 0 }
slices.Sort(es)
return es[len(es)-1]
}
func (j *JournalFile) GetPreviousEntry(current uint64) uint64 {
// returns timestamp, or 0 if not found
es := j.GetEntries()
if len(es) == 0 { return 0 }
slices.Sort(es)
last := uint64(0)
for _, ts := range es {
if current == ts {
return last
}
last = ts
}
return 0
}
func (j *JournalFile) GetNextEntry(current uint64) uint64 {
// returns timestamp, or 0 if not found
es := j.GetEntries()
if len(es) == 0 { return 0 }
slices.Sort(es)
lastWasCurrent := false
for _, ts := range es {
if lastWasCurrent {
return ts
}
if current == ts {
lastWasCurrent = true
}
}
return 0
}
func (j *JournalFile) GetEntry(ts uint64) *EncryptedEntry {
if j.closed { return nil }
e, found := j.entries[ts]
if !found { return nil }
return &e
}
func (j *JournalFile) AddEntry(e *EncryptedEntry) error {
if j.closed { return JournalClosed }
if _, exists := j.entries[e.Timestamp]; exists {
return EntryIdAlreadyExists
}
j.entries[e.Timestamp] = *e
j.needWrite = true
return nil
}
func (j *JournalFile) DeleteEntry(ts uint64) error {
if j.closed { return JournalClosed }
delete(j.entries, ts)
j.needWrite = true
return nil
}
func (j *JournalFile) Write() error {
if j.closed { return JournalClosed }
// check if the file was modified since the last check
mod, err := j.CheckIfExternallyModified()
if err != nil {
if !os.IsNotExist(err) {
return err
}
}
if mod {
return FileModifiedExternally
}
// write to file, if j.need_write
if j.needWrite {
// write to temporary file first, to prevent corrupted files
tmp := fmt.Sprintf("%s.tmp_%v", j.Filepath, time.Now().UnixMicro())
fTmp, err := os.OpenFile(tmp, os.O_WRONLY | os.O_CREATE, JournalFileMode)
if err != nil { return err }
_, err = fTmp.Write([]byte{j.Version})
if err != nil { return err }
es := []*EncryptedEntry{}
for _, v := range j.entries {
es = append(es, &v)
}
entryData := SerializeEntries(es)
_, err = fTmp.Write(entryData)
if err != nil { return err }
// move temporary file to real file
err = os.Rename(tmp, j.Filepath)
j.needWrite = false
}
err = j.updateLastModifiedTime()
return err
}
func (j *JournalFile) Close() {
j.Write()
j.closed = true
}
func (j *JournalFile) CheckIfExternallyModified() (modified bool, err error) {
f, err := os.Stat(j.Filepath)
if err != nil { return false, err }
t := f.ModTime()
return !j.statLastModTime.Equal(t), err
}
func (j *JournalFile) updateLastModifiedTime() error {
f, err := os.Stat(j.Filepath)
if err != nil { return err }
j.statLastModTime = f.ModTime()
return nil
}
func (j *JournalFile) read() error {
if j.closed { return JournalClosed }
// read from file (only at start or manually)
f, err := os.OpenFile(j.Filepath, os.O_RDONLY, JournalFileMode)
if err != nil { return err }
data, err := io.ReadAll(f)
j.Version = data[0]
// Check if version is supported
if j.Version != JournalFormatVersion {
return UnsupportedJournalVersion
}
// read entries
j.entries = map[uint64]EncryptedEntry{}
es := DeserializeEntries(data[JournalPos_Entries:])
for _, e := range es {
j.entries[e.Timestamp] = *e
}
err = j.updateLastModifiedTime()
return err
}
func OpenJournalFile(file string, password *memguard.Enclave) (*JournalFile, error) {
j := JournalFile{}
j.Filepath = file
// check file
fileinfo, err := os.Stat(j.Filepath)
if os.IsNotExist(err) {
// create reserved entry 0
e := &EncryptedEntry{Timestamp: 0}
cipherText, salt, noncePfx, err := EncryptText(password, rand.Text(), e.Timestamp)
if err != nil { return nil, err }
e.EncryptedText = cipherText
e.Salt = salt
e.NoncePfx = noncePfx
// init journal
j.Version = JournalFormatVersion
j.entries = map[uint64]EncryptedEntry{}
err = j.AddEntry(e); if err != nil { return &j, err }
j.needWrite = true
err = j.Write(); if err != nil { return &j, err }
} else {
if err != nil { return &j, err }
if fileinfo == nil {
return &j, UnknownFileReadErr
} else if fileinfo.IsDir() {
return &j, FilepathIsDirectory
}
}
err = j.read(); if err != nil { return &j, err }
// check password by decrypting reserved entry 0
_, err = j.GetEntry(0).Decrypt(password)
return &j, err
}
const MaxEntrySize = uint32(4294967295) // (2^32)-1
type EncryptedEntry struct {
Timestamp uint64 // Unix time in microseconds, works until year 294246
Salt [12]byte
NoncePfx [16]byte // Nonce = random 16 bytes prefix + 8 byte timestamp
EncryptedText []byte
}
func (e *EncryptedEntry) Decrypt(password *memguard.Enclave) (string, error) {
txt, err := DecryptText(password, e.EncryptedText, e.Salt, e.NoncePfx, e.Timestamp)
return txt, err
}
func (e *EncryptedEntry) EtLength() uint32 {
return uint32(len(e.EncryptedText))
}
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 {
return &e, err
}
e.EncryptedText = ct
e.Salt = s
e.NoncePfx = n
return &e, err
}
func SerializeEntries(es []*EncryptedEntry) []byte {
ees := []*encodedEntry{}
for _, e := range es {
ee := encodeEntry(e)
ees = append(ees, ee)
}
return serializeEncodedEntries(ees)
}
func DeserializeEntries(data []byte) []*EncryptedEntry {
ees := deserializeEncodedEntries(data)
es := []*EncryptedEntry{}
for _, ee := range ees {
e := decodeEntry(ee)
es = append(es, e)
}
return es
}
// very internal
type encodedEntry struct {
// all integers are ordered big-endian
Timestamp [8]byte // 0- 7 uint64
Salt [12]byte // 8-19
NoncePfx [16]byte // 20-35
CtLength [4]byte // 36-39
CipherText []byte // 40-... utf-8-encoded, encrypted
}
const payloadStart = 40
func encodeEntry(e *EncryptedEntry) *encodedEntry {
ee := encodedEntry{}
// timestamp
binary.BigEndian.PutUint64(ee.Timestamp[:], e.Timestamp)
// encrypt
ee.CipherText = e.EncryptedText
ee.Salt = e.Salt
ee.NoncePfx = e.NoncePfx
// length
binary.BigEndian.PutUint32(ee.CtLength[:], e.EtLength())
// done
return &ee
}
func decodeEntry(ee *encodedEntry) *EncryptedEntry {
e := EncryptedEntry{}
e.Timestamp = binary.BigEndian.Uint64(ee.Timestamp[:])
e.Salt = ee.Salt
e.NoncePfx = ee.NoncePfx
e.EncryptedText = ee.CipherText
return &e
}
func serializeEncodedEntries(ees []*encodedEntry) []byte {
b := []byte{}
for _, ee := range ees {
b = append(b, ee.Timestamp[:]...)
b = append(b, ee.Salt[:]...)
b = append(b, ee.NoncePfx[:]...)
b = append(b, ee.CtLength[:]...)
b = append(b, ee.CipherText...)
}
return b
}
func deserializeEncodedEntries(data []byte) []*encodedEntry {
ees := []*encodedEntry{}
lenD := len(data)
o := 0 // offset
for {
if lenD < o + payloadStart { break } // no more valid data.
ee := encodedEntry{}
ee.Timestamp = [8]byte(data[o+0:o+8])
ee.Salt = [12]byte(data[o+8:o+20])
ee.NoncePfx = [16]byte(data[o+20:o+36])
ee.CtLength = [4]byte(data[o+36:o+payloadStart])
ctLen := int(binary.BigEndian.Uint32(ee.CtLength[:]))
if lenD < o + payloadStart + ctLen { break } // no more valid data.
ee.CipherText = data[o+payloadStart:o+payloadStart+ctLen]
ees = append(ees, &ee)
o += payloadStart + int(ctLen)
}
return ees
}