package routes import ( "bytes" "context" "crypto/md5" "crypto/sha1" "fmt" "io" "net" "net/http" "net/mail" "net/url" "strings" "text/template" "time" "github.com/gorilla/mux" "github.com/lucasb-eyer/go-colorful" "github.com/sour-is/crypto/openpgp" "github.com/tv42/zbase32" "golang.org/x/crypto/openpgp/armor" "go.sour.is/paste/src/pkg/cache" "go.sour.is/paste/src/pkg/promise" "sour.is/x/toolbox/httpsrv" "sour.is/x/toolbox/log" ) var expireAfter = 20 * time.Minute func init() { cache, err := cache.NewARC(2048) if err != nil { panic(err) } tasker := promise.NewRunner(context.TODO(), promise.Timeout(30*time.Second), promise.WithCache(cache, expireAfter)) s := &identity{ cache: cache, tasker: tasker, } httpsrv.RegisterModule("identity", s.config) httpsrv.HttpRegister("identity", httpsrv.HttpRoutes{ {Name: "get", Method: "GET", Pattern: "/id/{id}", HandlerFunc: s.get}, }) } var pixl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" var defaultStyle = &Style{ Avatar: pixl, Cover: pixl, Background: pixl, Palette: getPalette("#93CCEA"), } type identity struct { cache cache.Cacher tasker promise.Tasker } type page struct { Entity *Entity Style *Style Proofs *Proofs IsComplete bool Err error } type Proofs map[string]*Proof func (s *identity) config(config map[string]string) {} // func (s *identity) runtoCache() func (s *identity) get(w http.ResponseWriter, r *http.Request) { secHeaders(w) vars := mux.Vars(r) id := vars["id"] ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() task := s.tasker.Run(EntityKey(id), func(q promise.Q) { ctx := q.Context() key := q.Key().(EntityKey) log.Infos("start task", fmt.Sprintf("%T", key), key) entity, err := s.getOpenPGPkey(ctx, string(key)) if err != nil { q.Reject(err) return } log.Infos("Scheduling Style", "email", entity.Primary.Address) q.Run(StyleKey(entity.Primary.Address), func(q promise.Q) { ctx := q.Context() key := q.Key().(StyleKey) log.Infos("start task", fmt.Sprintf("%T", key), key) style, err := s.getStyle(ctx, string(key)) if err != nil { q.Reject(err) return } log.Notice("Resolving Style") q.Resolve(style) }) go func() { log.Infos("Scheduling Proofs", "num", len(entity.Proofs)) for i := range entity.Proofs { q.Run(ProofKey(entity.Proofs[i]), func(q promise.Q) { key := q.Key().(ProofKey) proof := NewProof(string(key)) proof.Checked = true proof.Verified = true log.Notice("Resolving Proof") q.Resolve(proof) }) } }() log.Notice("Resolving Entity") q.Resolve(entity) }) page := page{Style: defaultStyle} select { case <-task.Await(): log.Info("Tasks Competed") if err := task.Err(); err != nil { page.Err = err page.IsComplete = true break } page.Entity = task.Result().(*Entity) case <-ctx.Done(): log.Info("Deadline Timeout") if e, ok := s.cache.Get(EntityKey(id)); ok { page.Entity = e.Value().(*Entity) } } if page.Entity != nil { var gotStyle, gotProofs bool if s, ok := s.cache.Get(StyleKey(page.Entity.Primary.Address)); ok { page.Style = s.Value().(*Style) gotStyle = true } // TODO: Proofs gotProofs = true if len(page.Entity.Proofs) > 0 { proofs := make(Proofs, len(page.Entity.Proofs)) for i := range page.Entity.Proofs { p := page.Entity.Proofs[i] proofs[p] = NewProof(p) if s, ok := s.cache.Get(ProofKey(p)); ok { proofs[p] = s.Value().(*Proof) } else { log.Info("Missing proof", p) gotProofs = false } } page.Proofs = &proofs } page.IsComplete = gotStyle && gotProofs } // e := json.NewEncoder(w) // e.SetIndent("", " ") // e.Encode(entity) t, err := template.New("identity").Parse(identityTPL) if err != nil { httpsrv.WriteText(w, 500, err.Error()) return } err = t.Execute(w, page) if err != nil { httpsrv.WriteText(w, 500, err.Error()) return } } func (s *identity) getOpenPGPkey(ctx context.Context, id string) (entity *Entity, err error) { useArmored := false addr := "" if isFingerprint(id) { addr = "https://keys.openpgp.org/vks/v1/by-fingerprint/" + strings.ToUpper(id) useArmored = true } else if email, err := mail.ParseAddress(id); err == nil { addr = getWKDPubKeyAddr(email) useArmored = false } else { return entity, fmt.Errorf("Parse address: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr, nil) if err != nil { return entity, err } cl := http.Client{} resp, err := cl.Do(req) if err != nil { return entity, fmt.Errorf("Requesting key: %w\nRemote URL: %v", err, addr) } if resp.StatusCode != 200 { return entity, fmt.Errorf("bad response from remote: %s\nRemote URL: %v", resp.Status, addr) } defer resp.Body.Close() if resp.Header.Get("Content-Type") == "application/pgp-keys" { useArmored = true } log.Infos("getIdentity", "id", id, "useArmored", useArmored, "status", resp.Status, "addr", addr) entity, err = ReadKey(resp.Body, useArmored) return entity, err } type EntityKey string func (k EntityKey) Key() interface{} { return k } type Entity struct { Primary *mail.Address Emails []*mail.Address Fingerprint string Proofs []string ArmorText string } func getEntity(lis openpgp.EntityList) (*Entity, error) { entity := &Entity{} var err error for _, e := range lis { if e == nil { continue } if e.PrimaryKey == nil { continue } entity.Fingerprint = fmt.Sprintf("%X", e.PrimaryKey.Fingerprint) for name, ident := range e.Identities { // Pick first identity if entity.Primary == nil { entity.Primary, err = mail.ParseAddress(name) if err != nil { return entity, err } } // If one is marked primary use that if ident.SelfSignature != nil && ident.SelfSignature.IsPrimaryId != nil && *ident.SelfSignature.IsPrimaryId { entity.Primary, err = mail.ParseAddress(name) if err != nil { return entity, err } } else { var email *mail.Address if email, err = mail.ParseAddress(name); err != nil { return entity, err } entity.Emails = append(entity.Emails, email) } // If identity is self signed read notation data. if ident.SelfSignature != nil && ident.SelfSignature.NotationData != nil { // Get proofs and append to list. if proofs, ok := ident.SelfSignature.NotationData["proof@metacode.biz"]; ok { entity.Proofs = append(entity.Proofs, proofs...) } } } break } if entity.Primary == nil { entity.Primary, _ = mail.ParseAddress("nobody@nodomain.xyz") } return entity, err } func ReadKey(r io.Reader, useArmored bool) (e *Entity, err error) { var buf bytes.Buffer var w io.Writer = &buf if !useArmored { var aw io.WriteCloser aw, err = armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", nil) if err != nil { return e, fmt.Errorf("Read key: %w", err) } defer func() { aw.Close(); e.ArmorText = buf.String() }() w = aw } else { defer func() { e.ArmorText = buf.String() }() } r = io.TeeReader(r, w) var lis openpgp.EntityList if useArmored { lis, err = openpgp.ReadArmoredKeyRing(r) } else { lis, err = openpgp.ReadKeyRing(r) } if err != nil { return e, fmt.Errorf("Read key: %w", err) } e, err = getEntity(lis) if err != nil { return e, fmt.Errorf("Parse key: %w", err) } return } func isFingerprint(s string) bool { for _, r := range s { switch r { case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F': default: return false } } return true } func getWKDPubKeyAddr(email *mail.Address) string { parts := strings.SplitN(email.Address, "@", 2) hash := sha1.Sum([]byte(parts[0])) lp := zbase32.EncodeToString(hash[:]) return fmt.Sprintf("https://%s/.well-known/openpgpkey/hu/%s", parts[1], lp) } type StyleKey string func (s StyleKey) Key() interface{} { return s } type Style struct { Avatar, Cover, Background string Palette []string } func (s *identity) getStyle(ctx context.Context, email string) (*Style, error) { avatarHost, styleHost, err := styleSRV(ctx, email) if err != nil { return nil, err } log.Infos("getStyle", "avatar", avatarHost, "style", styleHost) hash := md5.New() email = strings.TrimSpace(strings.ToLower(email)) _, _ = hash.Write([]byte(email)) id := hash.Sum(nil) style := &Style{} style.Palette = getPalette(fmt.Sprintf("#%x", id[:3])) style.Avatar = fmt.Sprintf("https://%s/avatar/%x", avatarHost, id) style.Cover = pixl style.Background = "https://lavana.sour.is/bg/52548b3dcb032882675afe1e4bcba0e9" if styleHost != "" { style.Cover = fmt.Sprintf("https://%s/cover/%x", styleHost, id) style.Background = fmt.Sprintf("https://%s/bg/%x", styleHost, id) } return style, err } func styleSRV(ctx context.Context, email string) (avatar string, style string, err error) { // Defaults style = "" avatar = "www.gravatar.com" parts := strings.SplitN(email, "@", 2) if _, srv, err := net.DefaultResolver.LookupSRV(ctx, "style-sec", "tcp", parts[1]); err == nil { if len(srv) > 0 { style = strings.TrimSuffix(srv[0].Target, ".") avatar = strings.TrimSuffix(srv[0].Target, ".") return avatar, style, err } } if _, srv, err := net.DefaultResolver.LookupSRV(ctx, "avatars-sec", "tcp", parts[1]); err == nil { if len(srv) > 0 { avatar = strings.TrimSuffix(srv[0].Target, ".") return avatar, style, err } } return } // getPalette maes a complementary color palette. https://play.golang.org/p/nBXLUocGsU5 func getPalette(hex string) []string { reference, _ := colorful.Hex(hex) reference = sat(lum(reference, 0, .5), 0, .5) white := colorful.Color{R: 1, G: 1, B: 1} black := colorful.Color{R: 0, G: 0, B: 0} accentA := hue(reference, 60) accentB := hue(reference, -60) accentC := hue(reference, -180) return append( []string{}, white.Hex(), lum(reference, .4, .6).Hex(), reference.Hex(), lum(reference, .4, 0).Hex(), black.Hex(), lum(accentA, .4, .6).Hex(), accentA.Hex(), lum(accentA, .4, 0).Hex(), lum(accentB, .4, .6).Hex(), accentB.Hex(), lum(accentB, .4, 0).Hex(), lum(accentC, .4, .6).Hex(), accentC.Hex(), lum(accentC, .4, 0).Hex(), ) } func hue(in colorful.Color, H float64) colorful.Color { h, s, l := in.Hsl() return colorful.Hsl(h+H, s, l) } func sat(in colorful.Color, S, V float64) colorful.Color { h, s, l := in.Hsl() return colorful.Hsl(h, V+s*S, l) } func lum(in colorful.Color, L, V float64) colorful.Color { h, s, l := in.Hsl() return colorful.Hsl(h, s, V+l*L) } type ProofKey string func (k ProofKey) Key() interface{} { return k } type Proof struct { Icon string Service string Name string URI string Link string Checked bool Verified bool } func NewProof(uri string) *Proof { p := &Proof{URI: uri} u, err := url.Parse(uri) if err != nil { p.Icon = "exclamation-triangle" p.Service = "error" p.Name = err.Error() return p } p.Service = u.Scheme switch u.Scheme { case "dns": p.Icon = "fas fa-globe" p.Name = u.Opaque p.Link = fmt.Sprintf("https://%s", u.Hostname()) case "xmpp": p.Icon = "fas fa-comments" p.Name = u.Opaque case "https": p.Icon = "fas fa-atlas" p.Name = u.Hostname() p.Link = fmt.Sprintf("https://%s", u.Hostname()) switch { case strings.HasPrefix(u.Host, "twitter.com"): p.Icon = "fab fa-twitter" p.Service = "Twitter" case strings.HasPrefix(u.Host, "news.ycombinator.com"): p.Icon = "fab fa-hacker-news" p.Service = "HackerNews" case strings.HasPrefix(u.Host, "dev.to"): p.Icon = "fab fa-dev" p.Service = "dev.to" case strings.HasPrefix(u.Host, "reddit.com"), strings.HasPrefix(u.Host, "www.reddit.com"): p.Icon = "fab fa-reddit" p.Service = "Reddit" case strings.HasPrefix(u.Host, "gist.github.com"): p.Icon = "fab fa-github" p.Service = "GitHub" case strings.HasPrefix(u.Host, "lobste.rs"): p.Icon = "fas fa-list-ul" p.Service = "Lobsters" case strings.HasSuffix(u.Host, "/gitlab_proof/"): p.Icon = "fab fa-gitlab" p.Service = "GetLab" case strings.HasSuffix(u.Host, "/gitea_proof/"): p.Icon = "fas fa-mug-hot" p.Service = "Gitea" default: p.Icon = "fas fa-project-diagram" p.Service = "fediverse" } } return p } func secHeaders(w http.ResponseWriter) { w.Header().Set("X-XSS-Protection", "1; mode=block") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Content-Type-Options", "nosniff") // w.Header().Set("Content-Security-Policy", "default-src 'self';") w.Header().Set("X-Content-Type-Options", "nosniff") } var identityTPL = `
{{if not .IsComplete}}{{end}} {{ with .Style }} {{end}}{{.}}
{{.Fingerprint}}
Reading key from remote service.