Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions caldav/issue1_crypto_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package caldav

import (
"bytes"
"errors"
"io"
"log"
"net/http"
"strings"
"testing"
"time"

"github.com/ProtonMail/go-crypto/openpgp"
"github.com/emersion/go-ical"
f "github.com/mathewcsims/protoxide/internal/issue1test"
"github.com/mathewcsims/protoxide/protonmail"
)

func issue1Card(t *testing.T, author *openpgp.Entity, body string) protonmail.CalendarEventCard {
t.Helper()
return protonmail.CalendarEventCard{Type: protonmail.CalendarEventCardSigned,
Data: body, Signature: f.Sign(t, author, body, f.Config(f.Epoch))}
}

// Semantic comparison deliberately ignores VCALENDAR serialization and property
// ordering. These synthetic fixtures contain one non-recurring UTC event only.
func issue1AssertEvent(t *testing.T, event *ical.Event, summary string) {
t.Helper()
for name, want := range map[string]string{ical.PropUID: "[email protected]", ical.PropSummary: summary} {
value, err := event.Props.Text(name)
f.Must(t, err)
if value != want {
t.Fatalf("%s semantic mismatch: got %q want %q", name, value, want)
}
}
start, err := event.DateTimeStart(nil)
f.Must(t, err)
end, err := event.DateTimeEnd(nil)
f.Must(t, err)
if !start.Equal(time.Date(2025, 1, 2, 12, 0, 0, 0, time.UTC)) || end.Sub(start) != 30*time.Minute {
t.Fatal("event time semantics changed")
}
}

func TestIssue1ReadEventCardValidSemantics(t *testing.T) {
author := f.Key(t, "author", f.Config(f.Epoch))
calendar := f.Key(t, "calendar", f.Config(f.Epoch))
for _, encrypted := range []bool{false, true} {
card := issue1Card(t, author, f.ICS)
key := ""
if encrypted {
card.Type = protonmail.CalendarEventCardEncryptedAndSigned
key, card.Data = f.Encrypt(t, calendar, f.ICS, false)
}
event := ical.NewEvent()
_, err := readEventCard(event, card, openpgp.EntityList{author}, openpgp.EntityList{calendar}, key)
f.Must(t, err)
issue1AssertEvent(t, event, "Synthetic baseline")
}
}

func TestIssue1ReadEventCardRejectsCiphertextBeforeMerge(t *testing.T) {
calendar := f.Key(t, "calendar", f.Config(f.Epoch))
for _, aead := range []bool{false, true} {
key, data := f.Encrypt(t, calendar, f.ICS, aead)
for _, mutation := range []string{"tampered-tail", "truncated", "missing-trailer"} {
event := ical.NewEvent()
event.Props.SetText(ical.PropSummary, "last trustworthy")
card := protonmail.CalendarEventCard{Type: protonmail.CalendarEventCardEncrypted, Data: f.Mutate(t, data, mutation)}
_, err := readEventCard(event, card, nil, openpgp.EntityList{calendar}, key)
if err == nil {
t.Fatalf("corrupt card accepted: aead=%v mutation=%s", aead, mutation)
}
summary, err := event.Props.Text(ical.PropSummary)
f.Must(t, err)
if summary != "last trustworthy" || len(event.Props) != 1 {
t.Fatal("failed card modified the merge target")
}
}
}
}

func TestIssue1BaselineServesUnverifiedSignatures(t *testing.T) {
author := f.Key(t, "author", f.Config(f.Epoch))
other := f.Key(t, "other", f.Config(f.Epoch))
const marker = "ISSUE1_SYNTHETIC_PRIVATE_SUMMARY"
body := strings.Replace(f.ICS, "Synthetic baseline", marker, 1)
for _, tc := range []struct{ name, signature string }{
{"invalid-mathematics", f.Sign(t, author, f.ICS, f.Config(f.Epoch))},
{"unknown-issuer", f.Sign(t, other, body, f.Config(f.Epoch))},
{"malformed", "malformed armored signature"},
} {
t.Run(tc.name, func(t *testing.T) {
var logs bytes.Buffer
oldWriter := log.Writer()
log.SetOutput(&logs)
defer log.SetOutput(oldWriter)
event := ical.NewEvent()
card := protonmail.CalendarEventCard{Type: protonmail.CalendarEventCardSigned, Data: body, Signature: tc.signature}
_, err := readEventCard(event, card, openpgp.EntityList{author}, nil, "")
f.Must(t, err)
issue1AssertEvent(t, event, marker)
if !strings.Contains(logs.String(), "unverified signature") {
t.Fatal("expected baseline verification warning absent")
}
if strings.Contains(logs.String(), marker) || strings.Contains(logs.String(), "BEGIN PGP") {
t.Fatal("fixture content or key armor leaked to diagnostics")
}
t.Log("KNOWN_GAP: baseline serves rejected signature; successful characterization is not security acceptance")
})
}
}

func TestIssue1BaselineMixedCardTrustAndDowngrades(t *testing.T) {
author := f.Key(t, "author", f.Config(f.Epoch))
good := issue1Card(t, author, f.ICS)
changed := strings.Replace(f.ICS, "Synthetic baseline", "Untrusted override", 1)
for _, tc := range []struct {
name string
kind protonmail.CalendarEventCardType
signature string
}{
{"invalid-signed-card", protonmail.CalendarEventCardSigned, good.Signature},
{"type-downgrade-to-clear", protonmail.CalendarEventCardClear, good.Signature},
{"signature-stripped", protonmail.CalendarEventCardSigned, ""},
{"unknown-type", protonmail.CalendarEventCardType(99), good.Signature},
} {
t.Run(tc.name, func(t *testing.T) {
candidate := &protonmail.CalendarEvent{
SharedEvents: []protonmail.CalendarEventCard{good},
CalendarEvents: []protonmail.CalendarEventCard{{Type: tc.kind, Data: changed, Signature: tc.signature}},
}
merged, err := toIcalCalendar(candidate, openpgp.EntityList{author}, nil)
f.Must(t, err)
events := merged.Events()
if len(events) != 1 {
t.Fatal("expected one merged fixture event")
}
issue1AssertEvent(t, &events[0], "Untrusted override")
t.Log("KNOWN_GAP: weaker calendar card overwrites authenticated shared-card properties")
})
}
t.Run("missing-all-required-cards", func(t *testing.T) {
merged, err := toIcalCalendar(&protonmail.CalendarEvent{}, nil, nil)
f.Must(t, err)
events := merged.Events()
if len(events) != 1 || events[0].Props.Get(ical.PropUID) != nil {
t.Fatal("baseline empty-card behavior changed; review the diagnosis")
}
t.Log("KNOWN_GAP: baseline constructs an event without required-card validation")
})
}

type issue1Transport func(*http.Request) (*http.Response, error)

func (transport issue1Transport) RoundTrip(req *http.Request) (*http.Response, error) {
return transport(req)
}

func TestIssue1KeyLookupFailureIsNotUnknownIssuer(t *testing.T) {
author := f.Key(t, "author", f.Config(f.Epoch))
lookupFailure := errors.New("synthetic key lookup unavailable")
for _, tc := range []struct {
name, response string
fail, wantError bool
}{
{"transport-failure", "", true, true},
{"empty-successful-lookup", `{"Code":1000,"Keys":[]}`, false, false},
{"malformed-key", `{"Code":1000,"Keys":[{"PublicKey":"malformed"}]}`, false, true},
} {
t.Run(tc.name, func(t *testing.T) {
calls := 0
client := &protonmail.Client{RootURL: "https://issue1.invalid", HTTPClient: &http.Client{
Transport: issue1Transport(func(req *http.Request) (*http.Response, error) {
calls++
if req.URL.Host != "issue1.invalid" || req.Method != http.MethodGet || req.URL.Path != "/keys" {
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.Path)
}
if tc.fail {
return nil, lookupFailure
}
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(tc.response)), Request: req}, nil
}),
}}
b := &backend{c: client, privateKeys: openpgp.EntityList{author}, keyCache: make(map[string]openpgp.EntityList)}
event := &protonmail.CalendarEvent{ID: "synthetic-event", Author: "[email protected]", SharedEvents: []protonmail.CalendarEventCard{issue1Card(t, author, f.ICS)}}
obj, err := getCalendarObject(b, "synthetic-calendar", nil, event, protonmail.CalendarSettings{})
if (err != nil) != tc.wantError {
t.Fatalf("got error=%v wantError=%v", err, tc.wantError)
}
if tc.fail && !errors.Is(err, lookupFailure) {
t.Fatalf("transport error identity lost: %v", err)
}
if calls != 1 {
t.Fatalf("expected one fake key lookup, got %d", calls)
}
if !tc.wantError {
if obj == nil {
t.Fatal("baseline did not serve unknown-issuer object")
}
t.Log("KNOWN_GAP: successful empty lookup becomes warning-only unknown-issuer acceptance")
}
})
}
}
Loading