commit 5ba1df24af17d3d2d416f3ae30df71146d0d721b Author: eyedeekay Date: Sat Nov 16 16:17:17 2024 -0500 Initial prototype of mail library diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2da19c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +go-i2p-smtp +i2pkeys +onionkeys +tlskeys \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/backend/mail.go b/backend/mail.go new file mode 100644 index 0000000..c0ff25a --- /dev/null +++ b/backend/mail.go @@ -0,0 +1,64 @@ +package i2pmail + +import ( + "os" + "sync" + + smtp "github.com/emersion/go-smtp" + "github.com/go-i2p/i2pkeys" + "github.com/go-i2p/sam3" +) + +type I2PMailBackend struct { + // Backend SAM management + samAddr string + sam *sam3.SAM + + // Backend file-storage management, backend can store data in the base, I2PMailSession will store data in a sub-directory + baseDataDir string + + // I2PMailSession variables, pass these to the I2PMailSession when constructing them + keys *i2pkeys.I2PKeys + session *sam3.StreamSession + mu sync.RWMutex +} + +func NewI2PMailBackend(samAddr, dataDir string) (*I2PMailBackend, error) { + if err := os.MkdirAll(dataDir, 0o700); err != nil { + return nil, err + } + + return &I2PMailBackend{ + samAddr: samAddr, + baseDataDir: dataDir, + }, nil +} + +func (b *I2PMailBackend) NewSession(c *smtp.Conn) (smtp.Session, error) { + return &I2PMailSession{ + session: b.getOrCreateSession(c.Hostname()), + }, nil +} + +func (b *I2PMailBackend) getOrCreateSession(id string) *sam3.StreamSession { + b.mu.Lock() + defer b.mu.Unlock() + + if b.session != nil { + return b.session + } + keys, err := i2pkeys.NewDestination() + if err != nil { + return nil + } + b.keys = keys + b.sam, err = sam3.NewSAM(b.samAddr) + if err != nil { + return nil + } + b.session, err = b.sam.NewStreamSession(id, *b.keys, sam3.Options_Default) + if err != nil { + return nil + } + return b.session +} diff --git a/backend/session.go b/backend/session.go new file mode 100644 index 0000000..1a6d981 --- /dev/null +++ b/backend/session.go @@ -0,0 +1,310 @@ +package i2pmail + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + + smtp "github.com/emersion/go-smtp" + "github.com/go-i2p/i2pkeys" + "github.com/go-i2p/sam3" + "golang.org/x/crypto/argon2" +) + +type UserData struct { + Username string + PassHash []byte + Salt []byte + LastAccess time.Time + Destination string +} + +type I2PMailSession struct { + from string + to []string + data []byte + session *sam3.StreamSession + baseDataDir string + userData *UserData + mu sync.RWMutex +} + +func (s *I2PMailSession) getUserPath(username string) string { + return filepath.Join(s.baseDataDir, "users", username+".json") +} + +func (s *I2PMailSession) getMailboxPath(username string) string { + return filepath.Join(s.baseDataDir, "mailboxes", username) +} + +func (s *I2PMailSession) hashPassword(password string, salt []byte) ([]byte, []byte, error) { + if salt == nil { + salt = make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return nil, nil, err + } + } + + // Argon2id for secure password hashing + hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32) + return hash, salt, nil +} + +func (s *I2PMailSession) loadUser(username string) (*UserData, error) { + path := s.getUserPath(username) + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var user UserData + if err := json.NewDecoder(f).Decode(&user); err != nil { + return nil, err + } + return &user, nil +} + +func (s *I2PMailSession) saveUser(user *UserData) error { + if err := os.MkdirAll(filepath.Dir(s.getUserPath(user.Username)), 0700); err != nil { + return err + } + + f, err := os.OpenFile(s.getUserPath(user.Username), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer f.Close() + + return json.NewEncoder(f).Encode(user) +} + +func (s *I2PMailSession) AuthPlain(username, password string) error { + s.mu.Lock() + defer s.mu.Unlock() + + user, err := s.loadUser(username) + if err != nil && !os.IsNotExist(err) { + return err + } + + // New user registration + if os.IsNotExist(err) { + hash, salt, err := s.hashPassword(password, nil) + if err != nil { + return err + } + + dest, err := i2pkeys.NewDestination() + if err != nil { + return err + } + + user = &UserData{ + Username: username, + PassHash: hash, + Salt: salt, + LastAccess: time.Now(), + Destination: dest.String(), + } + + if err := os.MkdirAll(s.getMailboxPath(username), 0700); err != nil { + return err + } + + if err := s.saveUser(user); err != nil { + return err + } + + s.userData = user + return nil + } + + // Existing user authentication + hash, _, err := s.hashPassword(password, user.Salt) + if err != nil { + return err + } + + if !compareHashes(hash, user.PassHash) { + return errors.New("invalid credentials") + } + + user.LastAccess = time.Now() + if err := s.saveUser(user); err != nil { + return err + } + + s.userData = user + return nil +} + +func (s *I2PMailSession) storeMail(from string, to string, data []byte) error { + mailboxPath := s.getMailboxPath(to) + if err := os.MkdirAll(mailboxPath, 0700); err != nil { + return err + } + + mailID := base64.RawURLEncoding.EncodeToString(generateRandomBytes(16)) + mailPath := filepath.Join(mailboxPath, mailID) + + mailData := struct { + From string + To string + Date time.Time + Content []byte + }{ + From: from, + To: to, + Date: time.Now(), + Content: data, + } + + f, err := os.OpenFile(mailPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + defer f.Close() + + return json.NewEncoder(f).Encode(mailData) +} + +func (s *I2PMailSession) deliverMail(to string) error { + conn, err := s.session.Dial("tcp", to) + if err != nil { + return err + } + defer conn.Close() + + if err := s.storeMail(s.from, to, s.data); err != nil { + return err + } + + _, err = conn.Write(s.data) + return err +} + +func compareHashes(a, b []byte) bool { + if len(a) != len(b) { + return false + } + + var result byte + for i := 0; i < len(a); i++ { + result |= a[i] ^ b[i] + } + return result == 0 +} + +func generateRandomBytes(n int) []byte { + b := make([]byte, n) + _, err := rand.Read(b) + if err != nil { + panic(err) + } + return b +} + +// Data implements smtp.Session. +func (s *I2PMailSession) Data(r io.Reader) error { + if s.userData == nil { + return errors.New("authentication required") + } + if len(s.to) == 0 { + return errors.New("no recipients specified") + } + + // Read email data with a reasonable size limit + data, err := io.ReadAll(io.LimitReader(r, 25*1024*1024)) // 25MB limit + if err != nil { + return err + } + s.data = data + + // Deliver to all recipients + var lastErr error + for _, recipient := range s.to { + if err := s.deliverMail(recipient); err != nil { + lastErr = err + } + } + + return lastErr +} + +// Mail implements smtp.Session. +func (s *I2PMailSession) Mail(from string, opts *smtp.MailOptions) error { + if s.userData == nil { + return errors.New("authentication required") + } + + // Validate sender is authenticated user + if from != s.userData.Username+"@"+s.userData.Destination { + return errors.New("sender not authorized") + } + + s.from = from + s.to = nil // Reset recipients + s.data = nil // Reset email data + return nil +} + +// Rcpt implements smtp.Session. +func (s *I2PMailSession) Rcpt(to string, opts *smtp.RcptOptions) error { + if s.userData == nil { + return errors.New("authentication required") + } + if s.from == "" { + return errors.New("sender not specified") + } + + // Validate recipient address format + if !strings.Contains(to, "@") { + return errors.New("invalid recipient address") + } + + // Extract destination from email address + parts := strings.Split(to, "@") + if len(parts) != 2 { + return errors.New("invalid recipient address format") + } + + // Validate destination is a valid I2P address + dest := parts[1] + if !strings.HasSuffix(dest, ".i2p") && !strings.HasSuffix(dest, ".b32.i2p") { + return errors.New("recipient must be an I2P destination") + } + + s.to = append(s.to, to) + return nil +} + +// Reset implements smtp.Session. +func (s *I2PMailSession) Reset() { + s.mu.Lock() + defer s.mu.Unlock() + + s.from = "" + s.to = nil + s.data = nil + // Note: We don't reset authentication state +} + +// Logout implements smtp.Session. +func (s *I2PMailSession) Logout() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.from = "" + s.to = nil + s.data = nil + s.userData = nil + return nil +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..684e664 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module github.com/go-i2p/go-i2p-smtp + +go 1.23.1 + +require ( + github.com/emersion/go-smtp v0.21.3 + github.com/go-i2p/i2pkeys v0.33.10-0.20241113193422-e10de5e60708 + github.com/go-i2p/onramp v0.33.9 + github.com/go-i2p/sam3 v0.33.9 +) + +require ( + github.com/cretz/bine v0.2.0 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + golang.org/x/crypto v0.11.0 // indirect + golang.org/x/net v0.12.0 // indirect + golang.org/x/sys v0.27.0 // indirect +) + +replace github.com/go-i2p/onramp => ../onramp diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d4b92f2 --- /dev/null +++ b/go.sum @@ -0,0 +1,44 @@ +github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo= +github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY= +github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= +github.com/go-i2p/i2pkeys v0.0.0-20241108200332-e4f5ccdff8c4/go.mod h1:m5TlHjPZrU5KbTd7Lr+I2rljyC6aJ88HdkeMQXV0U0E= +github.com/go-i2p/i2pkeys v0.33.10-0.20241113193422-e10de5e60708 h1:Tiy9IBwi21maNpK74yCdHursJJMkyH7w87tX1nXGWzg= +github.com/go-i2p/i2pkeys v0.33.10-0.20241113193422-e10de5e60708/go.mod h1:m5TlHjPZrU5KbTd7Lr+I2rljyC6aJ88HdkeMQXV0U0E= +github.com/go-i2p/onramp v0.33.9 h1:7KNyASNy3rfI/d1L39ZZRkn1RLsQGQRBjlt4r/eu9Q0= +github.com/go-i2p/onramp v0.33.9/go.mod h1:zv8NClW3XlfoB1wNK2ZbTwuBj6EB1TqN8ktlDt6naFU= +github.com/go-i2p/sam3 v0.33.9 h1:3a+gunx75DFc6jxloUZTAVJbdP6736VU1dy2i7I9fKA= +github.com/go-i2p/sam3 v0.33.9/go.mod h1:oDuV145l5XWKKafeE4igJHTDpPwA0Yloz9nyKKh92eo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= +golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..a6cb246 --- /dev/null +++ b/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + + "github.com/emersion/go-smtp" + i2pmail "github.com/go-i2p/go-i2p-smtp/backend" + "github.com/go-i2p/onramp" +) + +func main() { + fmt.Println("TEST1") + onramp.InitializeOnrampLogger() + garlic, err := onramp.NewGarlic("smtp-server", "127.0.0.1:7656", onramp.OPT_HUGE) + if err != nil { + panic(err) + } + fmt.Println("TEST2") + server := smtp.NewServer(&i2pmail.I2PMailBackend{}) + fmt.Println("TEST3") + listener, err := garlic.ListenTLS() + if err != nil { + panic(err) + } + fmt.Println("TEST4") + server.Domain = listener.Addr().String() + server.AllowInsecureAuth = true + if err := server.Serve(listener); err != nil { + panic(err) + } +} \ No newline at end of file