diff --git a/e2e/events_test.go b/e2e/events_test.go index 1e4226e..49b8498 100644 --- a/e2e/events_test.go +++ b/e2e/events_test.go @@ -2,7 +2,6 @@ package e2e import ( "encoding/binary" - "encoding/json" "fmt" "os" "path" @@ -60,9 +59,8 @@ func Test_AddEvent_CreatesJsonFile(t *testing.T) { t.Fatalf("failed to init repo: %v", err) } - id := uuid.New() eventIn := core.Event{ - Id: id, + Id: uuid.New(), Calendar: TestCalendarName, Title: "Foo Event", From: time.Now(), @@ -79,26 +77,10 @@ func Test_AddEvent_CreatesJsonFile(t *testing.T) { t.Errorf("failed to get home dir: %v", err) } - b, err := os.ReadFile(filepath.Join(home, filesystem.DirName, TestCalendarName, core.EventsDirName, fmt.Sprintf("%s.json", id))) + _, err = os.ReadFile(filepath.Join(home, filesystem.DirName, TestCalendarName, core.EventsDirName, fmt.Sprintf("%s.json", eventIn.Id))) if err != nil { t.Errorf("failed to read event json file: %v", err) } - - var parsedEvent struct { - Id uuid.UUID `json:"id"` - Title string `json:"title"` - } - err = json.Unmarshal(b, &parsedEvent) - if err != nil { - t.Fatalf("failed to parse event json file: %v", err) - } - - if parsedEvent.Id != id { - t.Errorf("id is not the same as input: \nin: %d\n!=\nfile: %v", 1, parsedEvent.Id) - } - if parsedEvent.Title != "Foo Event" { - t.Errorf("id is not the same as input: \nin: %s\n!=\nfile: %s", "Foo Event", parsedEvent.Title) - } } func Test_AddEventAndGetEvent_Works(t *testing.T) { diff --git a/go.mod b/go.mod index aa94b76..8b9e360 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,9 @@ require ( github.com/go-git/go-git/v5 v5.16.4 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 + github.com/jedisct1/go-aes-siv v1.0.0 github.com/rdleal/intervalst v1.5.0 + golang.org/x/crypto v0.49.0 ) require ( @@ -26,12 +28,11 @@ require ( github.com/sergi/go-diff v1.4.0 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.46.0 // indirect golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect golang.org/x/mod v0.31.0 // indirect - golang.org/x/net v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/tools v0.40.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index ffe52cd..15805a8 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jedisct1/go-aes-siv v1.0.0 h1:1LUqYiKEvQhv1Pmwgi5DQMPPFxqT0cWmMPnzCNVYCO8= +github.com/jedisct1/go-aes-siv v1.0.0/go.mod h1:Vugw21e5SVYJkUjKdP+Qqeqv3Dw6+0cBe8wPvVUSBIE= github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -74,8 +76,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g= @@ -83,8 +85,8 @@ golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFn golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -93,14 +95,14 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= diff --git a/pkg/core/encryption/derivekey.go b/pkg/core/encryption/derivekey.go new file mode 100644 index 0000000..9ef77d4 --- /dev/null +++ b/pkg/core/encryption/derivekey.go @@ -0,0 +1,19 @@ +//go:build !js + +package encryption + +import ( + "golang.org/x/crypto/argon2" +) + +// Creates a key of length 'size' based on the provided 'password' plus 'salt'. +func deriveKey(password string, salt []byte, size uint32) []byte { + return argon2.IDKey( + []byte(password), + salt, + 1, // iterations + 64*1024, // memory (64 MB) + 4, // threads + size, + ) +} diff --git a/pkg/core/encryption/derivekey_wasm.go b/pkg/core/encryption/derivekey_wasm.go new file mode 100644 index 0000000..c63355e --- /dev/null +++ b/pkg/core/encryption/derivekey_wasm.go @@ -0,0 +1,21 @@ +//go:build js && wasm + +package encryption + +import ( + "golang.org/x/crypto/argon2" +) + +// WASM version with smaller memory and CPUs. +// +// Creates a key of length 'size' based on the provided 'password' plus 'salt'. +func deriveKey(password string, salt []byte, size uint32) []byte { + return argon2.IDKey( + []byte(password), + salt, + 1, // iterations + 16*1024, // memory (16 MB) + 1, // 1 thread in browser + size, + ) +} diff --git a/pkg/core/encryption/encryption.go b/pkg/core/encryption/encryption.go new file mode 100644 index 0000000..7e489d2 --- /dev/null +++ b/pkg/core/encryption/encryption.go @@ -0,0 +1,229 @@ +package encryption + +import ( + "encoding/base64" + "errors" + "fmt" + "reflect" + "slices" + "strconv" + "strings" + "time" + + aessiv "github.com/jedisct1/go-aes-siv" +) + +var siv *aessiv.AESSIV + +func SetPassword(password string) error { + var err error + key := deriveKey(password, []byte("some aditional data"), aessiv.KeySize256) + siv, err = aessiv.New(key) + if err != nil { + return fmt.Errorf("failed to create encryption instance from password: %w", err) + } + return nil +} + +func EncryptFields(v any) (any, error) { + if siv == nil { + return v, nil // skip if no key/instance + } + + val := reflect.ValueOf(v) + // handle pointers by dereferencing to get to the actual value + for val.Kind() == reflect.Pointer { + if val.IsNil() { + return "", nil + } + val = val.Elem() + } + typ := val.Type() + + out := make(map[string]any) + + // iterate through fields + for i := 0; i < val.NumField(); i++ { + fieldValue := val.Field(i) + fieldType := typ.Field(i) + fieldName := fieldType.Name + fieldKind := fieldValue.Kind() + + // respect the `json:"-"` + if fieldType.Tag.Get("json") == "-" { + continue + } + + // respect the `json:"omitempty"` + if fieldValue.IsZero() && hasOmitzero(fieldType) { + continue + } + + // recursively for structs (or pointer to struct) + if fieldKind == reflect.Struct || (fieldKind == reflect.Pointer && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) { + // we only recurse if it's NOT a time.Time + if _, ok := fieldValue.Interface().(time.Time); !ok { + nested, err := EncryptFields(fieldValue.Interface()) + if err != nil { + return nil, err + } + out[fieldName] = nested + continue + } + } + + // encrypt basic types + plaintext, err := encodeValue(fieldValue) + if err != nil { + return nil, fmt.Errorf("failed to encode field %s to string: %w", fieldName, err) + } + ciphertext := siv.Seal(nil, nil, []byte(plaintext), []byte(fieldName)) + out[fieldName] = base64.StdEncoding.EncodeToString(ciphertext) + } + + return out, nil +} + +// Decrypts everything from raw map to v. v has to be a pointer to struct. +func DecryptFields(v any, raw map[string]any) error { + if siv == nil { + return nil // skip if no key/instance + } + + val := reflect.ValueOf(v) + if val.Kind() != reflect.Pointer || val.Elem().Kind() != reflect.Struct { + return errors.New("v must be a pointer to a struct") + } + + val = val.Elem() + typ := val.Type() + + for i := 0; i < val.NumField(); i++ { + fieldValue := val.Field(i) + fieldType := typ.Field(i) + fieldName := fieldType.Name + + // skip if field is unexported or marked to be ignored + if !fieldValue.CanSet() || fieldType.Tag.Get("json") == "-" { + continue + } + + // get the value from the map using the struct field name + data, ok := raw[getJsonFieldName(fieldType)] + if !ok || data == nil { + continue + } + + // recursive structs + if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Pointer && fieldType.Type.Elem().Kind() == reflect.Struct) { + // don't recurse time.Time + if _, isTime := fieldValue.Interface().(time.Time); !isTime { + nestedMap, isMap := data.(map[string]any) + if isMap { + // initialize pointer if nil + if fieldValue.Kind() == reflect.Pointer && fieldValue.IsNil() { + fieldValue.Set(reflect.New(fieldValue.Type().Elem())) + } + if err := DecryptFields(fieldValue.Addr().Interface(), nestedMap); err != nil { + return err + } + continue + } + } + } + + // decrypt encrypted fields + cipherStr, ok := data.(string) + if !ok { + continue + } + + ciphertext, err := base64.StdEncoding.DecodeString(cipherStr) + if err != nil { + return fmt.Errorf("failed to decode base64 string of field %s: %w", fieldName, err) + } + plaintext, err := siv.Open(nil, nil, ciphertext, []byte(fieldName)) + if err != nil { + return fmt.Errorf("failed to decrypt field %s: %w", fieldName, err) + } + + // convert decrypted string back to the field's actual type + if err := decodeValue(fieldValue, string(plaintext)); err != nil { + return fmt.Errorf("failed to parse field %s: %w", fieldName, err) + } + } + + return nil +} + +// Helper that returns the string representation of field type. +func encodeValue(field reflect.Value) (string, error) { + // dereference pointer if necessary + for field.Kind() == reflect.Pointer { + if field.IsNil() { + return "", nil + } + field = field.Elem() + } + + switch field.Kind() { + case reflect.String: + return field.String(), nil + case reflect.Int, reflect.Int64, reflect.Int32, reflect.Int16, reflect.Int8: + return strconv.FormatInt(field.Int(), 10), nil + case reflect.Struct: + if t, ok := field.Interface().(time.Time); ok { + return t.UTC().Format(time.RFC3339), nil + } + return "", nil + default: + return fmt.Sprintf("%v", field.Interface()), nil + } +} + +// Helper that converts string representation of a value back into its field type. +func decodeValue(field reflect.Value, val string) error { + switch field.Kind() { + case reflect.String: + field.SetString(val) + case reflect.Int, reflect.Int64, reflect.Int32, reflect.Int16, reflect.Int8: + i, err := strconv.ParseInt(val, 10, 64) + if err != nil { + return err + } + field.SetInt(i) + case reflect.Struct: + if _, ok := field.Interface().(time.Time); ok { + t, err := time.Parse(time.RFC3339, val) + if err != nil { + return err + } + field.Set(reflect.ValueOf(t)) + } + } + return nil +} + +// Returns true if `json:"field_name,omitzero"` and false if `json:"field_name"`. +func hasOmitzero(field reflect.StructField) bool { + tag := field.Tag.Get("json") + tagParts := strings.Split(tag, ",") + return slices.Contains(tagParts, "omitzero") +} + +// Returns the "field_name" from: +// +// FieldName `json:"field_name"` +// +// Returns "FieldName" if not present. +func getJsonFieldName(field reflect.StructField) string { + tag := field.Tag.Get("json") + tagParts := strings.Split(tag, ",") // always len > 0 + if tagParts[0] == "-" { + return "" + } + if len(tagParts[0]) != 0 { + return tagParts[0] + } + return field.Name // fallback to struct field name instead of json name +} diff --git a/pkg/core/encryption/encryption_test.go b/pkg/core/encryption/encryption_test.go new file mode 100644 index 0000000..67882f6 --- /dev/null +++ b/pkg/core/encryption/encryption_test.go @@ -0,0 +1,90 @@ +package encryption + +import ( + "reflect" + "testing" +) + +func Test_hasOmitzero(t *testing.T) { + type testStruct struct { + WithOmitzero string `json:"field,omitzero"` + WithoutOmitzero string `json:"field2"` + } + typ := reflect.TypeOf(testStruct{}) + + tests := []struct { + name string + field reflect.StructField + want bool + }{ + { + name: "has omitzero", + field: typ.Field(0), + want: true, + }, + { + name: "does not have omitzero", + field: typ.Field(1), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hasOmitzero(tt.field); got != tt.want { + t.Errorf("hasOmitzero() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_getJsonFieldName(t *testing.T) { + type testStruct struct { + Normal string `json:"field"` + WithoutOmitzero string `json:"field2,omitzero"` + NoTag string + Ignore string `json:"-"` + EmptyName string `json:",omitzero"` + } + typ := reflect.TypeOf(testStruct{}) + + tests := []struct { + name string + field reflect.StructField + want string + }{ + { + name: "simple name", + field: typ.Field(0), + want: "field", + }, + { + name: "name with option", + field: typ.Field(1), + want: "field2", + }, + { + name: "no tag uses field name", + field: typ.Field(2), + want: "NoTag", + }, + { + name: "ignored field", + field: typ.Field(3), + want: "", + }, + { + name: "empty name in tag falls back to field name", + field: typ.Field(4), + want: "EmptyName", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getJsonFieldName(tt.field); got != tt.want { + t.Errorf("getJsonFieldName() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/core/event.go b/pkg/core/event.go index 3bb9850..a786a7f 100644 --- a/pkg/core/event.go +++ b/pkg/core/event.go @@ -1,32 +1,35 @@ package core import ( + "encoding/json" "errors" "fmt" "time" + "github.com/firu11/git-calendar-core/pkg/core/encryption" "github.com/google/uuid" + // deterministic AEAD ) type Event struct { - Id uuid.UUID `json:"id"` // use UUIDv4; shouldn't change (different id = different event) + Id uuid.UUID `json:"-"` // use UUIDv4; shouldn't change (different id = different event) Title string `json:"title"` - Location string `json:"location"` - Description string `json:"description"` + Location string `json:"location,omitzero"` + Description string `json:"description,omitzero"` From time.Time `json:"from"` To time.Time `json:"to"` Calendar string `json:"calendar"` Tag string `json:"tag"` - MasterId uuid.UUID `json:"master_id"` // uuid.Nil if basic event or repeating master event - Repeat *Repetition `json:"repeat"` // nil if slave + MasterId uuid.UUID `json:"-"` // uuid.Nil if basic event or repeating master event + Repeat *Repetition `json:"repeat,omitzero"` // nil if slave } type Repetition struct { - Frequency Freq `json:"frequency"` // Day, Week, ... (None if master) - Interval int `json:"interval"` // 1..N (freq:Week + interval:2 => every other week) - Until time.Time `json:"until"` // the end of repetition by timestamp - Count int `json:"count"` // or by number of occurrences (only one condition can be present not both) - Exceptions []uuid.UUID `json:"exceptions"` // an array of slaves ids + Frequency Freq `json:"frequency"` // Day, Week, ... (None if master) + Interval int `json:"interval"` // 1..N (freq:Week + interval:2 => every other week) + Until time.Time `json:"until,omitzero"` // the end of repetition by timestamp + Count int `json:"count,omitzero"` // or by number of occurrences (only one condition can be present not both) + Exceptions []uuid.UUID `json:"exceptions"` // an array of slaves ids } func (e *Event) Validate() error { @@ -94,3 +97,23 @@ func (e Event) getTreeEndTime() time.Time { } return eventEnd } + +func (e *Event) MarshalJSON() ([]byte, error) { + type plainEvent Event // create a new type based on Event just to strip away its methods to avoid infinite recursion of MarshalJSON() + + enc, err := encryption.EncryptFields((*plainEvent)(e)) + if err != nil { + return nil, err + } + + return json.Marshal(enc) +} + +func (e *Event) UnmarshalJSON(data []byte) error { + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + return encryption.DecryptFields(e, raw) +}