From a90327bcc0f46171e30a4a549fb5b44f8e91e303 Mon Sep 17 00:00:00 2001 From: tjp Date: Mon, 8 Jan 2024 11:10:24 -0700 Subject: identity management and use --- actions.go | 30 +++++++-- command.go | 79 ++++++++++++++++++++++- files.go | 141 +++++++++++++++++++++++++++++++++++++++++ help.go | 34 ++++++++-- identity.go | 205 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 13 ++-- state.go | 7 ++- tls.go | 90 ++++++++++++++++++++++++-- 8 files changed, 575 insertions(+), 24 deletions(-) create mode 100644 identity.go diff --git a/actions.go b/actions.go index 2faf859..bf4d4c9 100644 --- a/actions.go +++ b/actions.go @@ -18,12 +18,6 @@ import ( "tildegit.org/tjp/sliderule/gopher" ) -var client sliderule.Client - -func init() { - client = sliderule.NewClient(nil) -} - var ( ErrMustBeOnAPage = errors.New("you must be on a page to do that, use the \"go\" command first") ErrNoPreviousHistory = errors.New("there is no previous page in the history") @@ -117,6 +111,8 @@ func Reload(state *BrowserState, conf *Config) error { urlStr, _ = gopherURL(state.Url) } + var client = sliderule.NewClient(tlsConfig(state)) + var response *sliderule.Response var err error if state.Url.Scheme == "spartan" && state.Url.Fragment == "prompt" { @@ -580,6 +576,28 @@ func TourCmd(state *BrowserState, args []string, conf *Config) error { return ErrInvalidTourArgs } +func IdentityCmd(state *BrowserState, args []string) error { + switch args[0] { + case "create": + return IdentityCreate(state, args[1]) + case "list": + return IdentityList(state) + case "delete": + return IdentityDelete(state, args[1]) + case "use": + switch args[2] { + case "domain": + return IdentityUseDomain(state, args[1], args[3]) + case "folder": + return IdentityUseFolder(state, args[1], args[3]) + case "page": + return IdentityUsePage(state, args[1], args[3]) + } + } + + return ErrInvalidArgs +} + func Pipe(state *BrowserState, cmdStr string) error { if state.Body == nil { return ErrMustBeOnAPage diff --git a/command.go b/command.go index d6d896f..758b764 100644 --- a/command.go +++ b/command.go @@ -73,6 +73,14 @@ func ParseCommand(line string) (*Command, error) { } } + case 'i': + if strings.HasPrefix("identity", cmd) { + args, err := parseIdentityArgs(rest) + if err != nil { + return nil, err + } + return &Command{Name: "identity", Args: args}, nil + } case 'n': if strings.HasPrefix("next", cmd) { return &Command{Name: "next"}, nil @@ -157,11 +165,11 @@ func ParseCommand(line string) (*Command, error) { } func parseMarkArgs(line string) ([]string, error) { - if line == "" { - return nil, ErrInvalidArgs + fields := strings.Fields(line) + if len(fields) == 0 { + return []string{"list"}, nil } - fields := strings.Fields(line) switch fields[0][0] { case 'a': if strings.HasPrefix("add", fields[0]) { @@ -291,6 +299,69 @@ func parseTourArgs(line string) ([]string, error) { return append([]string{"add"}, fields...), nil } +func parseIdentityArgs(line string) ([]string, error) { + fields := strings.Fields(line) + if len(fields) == 0 { + return []string{"list"}, nil + } + + switch fields[0][0] { + case 'c': + if strings.HasPrefix("create", fields[0]) { + fields[0] = "create" + if len(fields) != 2 { + return nil, ErrInvalidArgs + } + return fields, nil + } + case 'l': + if strings.HasPrefix("list", fields[0]) { + if len(fields) != 1 { + return nil, ErrInvalidArgs + } + return []string{"list"}, nil + } + case 'd': + if strings.HasPrefix("delete", fields[0]) { + fields[0] = "delete" + if len(fields) != 2 { + return nil, ErrInvalidArgs + } + return fields, nil + } + case 'u': + if strings.HasPrefix("use", fields[0]) { + fields[0] = "use" + if len(fields) != 4 { + return nil, ErrInvalidArgs + } + switch fields[2][0] { + case 'd': + if !strings.HasPrefix("domain", fields[2]) { + return nil, ErrInvalidArgs + } + fields[2] = "domain" + case 'f': + if !strings.HasPrefix("folder", fields[2]) { + return nil, ErrInvalidArgs + } + fields[2] = "folder" + case 'p': + if !strings.HasPrefix("page", fields[2]) { + return nil, ErrInvalidArgs + } + fields[2] = "page" + default: + return nil, ErrInvalidArgs + } + + return fields, nil + } + } + + return nil, ErrInvalidArgs +} + func RunCommand(conf *Config, cmd *Command, state *BrowserState) error { switch cmd.Name { case "about": @@ -337,6 +408,8 @@ func RunCommand(conf *Config, cmd *Command, state *BrowserState) error { return Mark(state, cmd.Args, conf) case "tour": return TourCmd(state, cmd.Args, conf) + case "identity": + return IdentityCmd(state, cmd.Args) case "quit": os.Exit(0) } diff --git a/files.go b/files.go index e602fca..f9be89a 100644 --- a/files.go +++ b/files.go @@ -2,6 +2,8 @@ package main import ( "bufio" + "crypto/tls" + "encoding/pem" "errors" "fmt" "net/url" @@ -264,3 +266,142 @@ func ensurePath(fpath string) error { } return nil } + +func getIdentities() (Identities, error) { + idents := Identities{ + ByName: map[string]*tls.Config{}, + ByDomain: map[string]*tls.Config{}, + ByFolder: map[string]*tls.Config{}, + ByPage: map[string]*tls.Config{}, + } + + manifest, err := dataFilePath("identities") + if err != nil { + return idents, err + } + + f, err := os.Open(manifest) + if err != nil { + return idents, err + } + defer func() { _ = f.Close() }() + + var curident *tls.Config + rdr := bufio.NewScanner(f) + for rdr.Scan() { + line := rdr.Text() + if strings.HasPrefix(line, ":") { + kind, location, _ := strings.Cut(line[1:], " ") + switch kind { + case "domain": + idents.ByDomain[location] = curident + case "folder": + idents.ByFolder[location] = curident + case "page": + idents.ByPage[location] = curident + } + } else { + name := strings.TrimSuffix(line, ":") + curident, err = getIdentity(name) + if err != nil { + return idents, err + } + idents.ByName[name] = curident + } + } + if err := rdr.Err(); err != nil { + return idents, err + } + + return idents, nil +} + +func saveIdentities(idents Identities) error { + manifest, err := dataFilePath("identities") + if err != nil { + return err + } + + f, err := os.OpenFile(manifest, os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + for name, ident := range idents.ByName { + if _, err := fmt.Fprintf(f, "%s:\n", name); err != nil { + return err + } + + for domain, id := range idents.ByDomain { + if id != ident { + continue + } + if _, err := fmt.Fprintf(f, ":domain %s\n", domain); err != nil { + return err + } + } + for folder, id := range idents.ByFolder { + if id != ident { + continue + } + if _, err := fmt.Fprintf(f, ":folder %s\n", folder); err != nil { + return err + } + } + for page, id := range idents.ByPage { + if id != ident { + continue + } + if _, err := fmt.Fprintf(f, ":page %s\n", page); err != nil { + return err + } + } + } + + return nil +} + +func getIdentity(name string) (*tls.Config, error) { + fpath, err := dataFilePath("ident/" + name) + if err != nil { + return nil, err + } + + cert, err := tls.LoadX509KeyPair(fpath, fpath) + if err != nil { + return nil, err + } + + return identityForCert(cert), nil +} + +func saveIdentity(name string, privkeyDER, certDER []byte) (string, error) { + fpath, err := dataFilePath("ident/" + name) + if err != nil { + return "", err + } + + f, err := os.OpenFile(fpath, os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return "", err + } + defer func() { _ = f.Close() }() + + if err := pem.Encode(f, &pem.Block{Type: "PRIVATE KEY", Bytes: privkeyDER}); err != nil { + return "", err + } + if err := pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil { + return "", err + } + + return fpath, nil +} + +func removeIdentity(name string) error { + fpath, err := dataFilePath("ident/" + name) + if err != nil { + return err + } + return os.Remove(fpath) +} diff --git a/help.go b/help.go index e427522..6e50ff7 100644 --- a/help.go +++ b/help.go @@ -29,8 +29,9 @@ help topics commands: Basics of x-1 commands, and a full listing of them. Each command also has its own help topic. urls: The forms of URLs which can be entered into x-1 commands. -mark: Information on the "mark" meta-command. -tour: Information about the "tour" meta-command. +mark: Information on the bookmarks and the "mark" meta-command. +tour: Information about tours and the "tour" meta-command. +identity: Identities and managing them with the "identity" meta-command. config: The x-1 configuration file. `[1:], @@ -42,7 +43,7 @@ back forward next previous reload print pipe help links history -tour mark +tour mark identity go save about quit @@ -114,7 +115,8 @@ look them up again. Marks are preserved across x-1 sessions. The mark meta-command has multiple sub-commands which can be used to manage and navigate to your saved marks. "m[ark] X" with any mark name -or unique prefix of a name can be used as "mark go". +or unique prefix of a name can be used as "mark go", and "m[ark]" alone +is treated as "mark list". m[ark] a[dd] NAME URL: adds a new name/url pair to your saved marks. m[ark] g[o] NAME: navigates to the named mark's URL. @@ -145,6 +147,30 @@ t[our] s[elect] [NAME]: make the named tour active (optionally named by a unique prefix), or without a name, selects the default tour. `[1:], + "identity": ` +i[dentity] +---------- +An identity is a managed credential in the form of a TLS client +certificate. This meta-command supports managing your various identities +and assigning them to be used on particular domains or on specific +pages. + +i[dentity] c[reate] NAME: create a new identity (TLS key/certificate). +i[dentity] l[ist]: list identities and the domains and paths on which + they are assigned to be used. +i[dentity] u[se] NAME d[omain] DOMAIN: assign the named identity to be + used across a given domain. +i[dentity] u[se] NAME f[older] URL: assign an identity to be used on any + path which has URL as a prefix. +i[dentity] u[se] NAME p[age] URL: always use the named identity on a + specific page. +i[dentity] d[elete] NAME: remove the named identity and any + domain/folder/page associations it has. + +Any "identity use" command will override existing associations to the +same domain/folder/page. +`[1:], + "root": ` r[oot] ------ diff --git a/identity.go b/identity.go new file mode 100644 index 0000000..891c01b --- /dev/null +++ b/identity.go @@ -0,0 +1,205 @@ +package main + +import ( + "bytes" + "crypto/tls" + "errors" + "fmt" + "io" + "net/url" + "os" + "strings" +) + +type Identities struct { + ByName map[string]*tls.Config + ByDomain map[string]*tls.Config + ByPage map[string]*tls.Config + ByFolder map[string]*tls.Config +} + +func findIdentity(state *BrowserState, prefix string) (string, error) { + found := 0 + value := "" + for name := range state.Identities.ByName { + if strings.HasPrefix(name, prefix) { + found += 1 + value = name + } + } + + switch found { + case 0: + return "", errors.New("no matching identity found") + case 1: + return value, nil + default: + return "", fmt.Errorf("too ambiguous - that name matched %d identities", found) + } +} + +func (ids Identities) Get(u *url.URL) *tls.Config { + if conf, ok := ids.ByPage[u.String()]; ok { + return conf + } + + pathsegments := strings.Split(strings.TrimLeft(u.Path, "/"), "/") + for len(pathsegments) > 0 { + pathsegments = pathsegments[0 : len(pathsegments)-1] + if conf, ok := ids.ByFolder[u.Hostname()+"/"+strings.Join(pathsegments, "/")]; ok { + return conf + } + } + + if conf, ok := ids.ByDomain[u.Hostname()]; ok { + return conf + } + + return nil +} + +func IdentityCreate(state *BrowserState, name string) error { + ident, err := createIdentity(state, name) + if err != nil { + return err + } + state.Identities.ByName[name] = ident + return saveIdentities(state.Identities) +} + +func IdentityList(state *BrowserState) error { + buf := &bytes.Buffer{} + for name, ident := range state.Identities.ByName { + if _, err := fmt.Fprintf(buf, "%s:\n", name); err != nil { + return err + } + + for domain, id := range state.Identities.ByDomain { + if id == ident { + if _, err := fmt.Fprintf(buf, " domain %s\n", domain); err != nil { + return err + } + } + } + for folder, id := range state.Identities.ByFolder { + if id == ident { + if _, err := fmt.Fprintf(buf, " folder %s\n", folder); err != nil { + return err + } + } + } + for page, id := range state.Identities.ByPage { + if id == ident { + if _, err := fmt.Fprintf(buf, " page %s\n", page); err != nil { + return err + } + } + } + } + + _, err := io.Copy(os.Stdout, buf) + return err +} + +func IdentityDelete(state *BrowserState, name string) error { + name, err := findIdentity(state, name) + if err != nil { + return err + } + + ident := state.Identities.ByName[name] + delete(state.Identities.ByName, name) + + for domain, id := range state.Identities.ByDomain { + if id == ident { + delete(state.Identities.ByDomain, domain) + } + } + for folder, id := range state.Identities.ByFolder { + if id == ident { + delete(state.Identities.ByFolder, folder) + } + } + for page, id := range state.Identities.ByPage { + if id == ident { + delete(state.Identities.ByPage, page) + } + } + + if err := removeIdentity(name); err != nil { + return err + } + return saveIdentities(state.Identities) +} + +func IdentityUseDomain(state *BrowserState, name string, domain string) error { + name, err := findIdentity(state, name) + if err != nil { + return err + } + ident := state.Identities.ByName[name] + + u, _, err := parseURL(domain, state, "gemini") + if errors.Is(err, ErrInvalidLink) { + u, err = url.Parse(domain) + if err != nil { + return ErrInvalidLink + } + if u.Hostname() == "" { + u.Host = domain + } + } else if err != nil { + return err + } + + state.Identities.ByDomain[u.Hostname()] = ident + return saveIdentities(state.Identities) +} + +func IdentityUseFolder(state *BrowserState, name string, domain string) error { + name, err := findIdentity(state, name) + if err != nil { + return err + } + ident := state.Identities.ByName[name] + + u, _, err := parseURL(domain, state, "gemini") + if errors.Is(err, ErrInvalidLink) { + u, err = url.Parse(domain) + if err != nil { + return ErrInvalidLink + } + if u.Hostname() == "" { + u.Host = domain + } + } else if err != nil { + return err + } + + state.Identities.ByFolder[fmt.Sprintf("%s/%s", u.Hostname(), u.Path)] = ident + return saveIdentities(state.Identities) +} + +func IdentityUsePage(state *BrowserState, name string, domain string) error { + name, err := findIdentity(state, name) + if err != nil { + return err + } + ident := state.Identities.ByName[name] + + u, _, err := parseURL(domain, state, "gemini") + if errors.Is(err, ErrInvalidLink) { + u, err = url.Parse(domain) + if err != nil { + return ErrInvalidLink + } + if u.Hostname() == "" { + u.Host = domain + } + } else if err != nil { + return err + } + + state.Identities.ByPage[u.String()] = ident + return saveIdentities(state.Identities) +} diff --git a/main.go b/main.go index 6b0a9f8..fd3803e 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/chzyer/readline" - "tildegit.org/tjp/sliderule" ) func main() { @@ -21,11 +20,7 @@ func main() { log.Fatal(err) } - client = sliderule.NewClient(tlsConfig()) - - state := NewBrowserState() - state.Quiet = conf.Quiet - state.Pager = conf.Pager + state := NewBrowserState(conf) rl, err := readline.New(Prompt) if err != nil { @@ -44,6 +39,12 @@ func main() { } state.NamedTours = tours + idents, err := getIdentities() + if err != nil { + log.Fatal(err) + } + state.Identities = idents + if conf.VimKeys { rl.SetVimMode(true) } diff --git a/state.go b/state.go index 6a6c885..a967b7b 100644 --- a/state.go +++ b/state.go @@ -13,6 +13,8 @@ type BrowserState struct { Marks map[string]string + Identities Identities + NamedTours map[string]*Tour DefaultTour Tour CurrentTour *Tour @@ -48,13 +50,16 @@ type Link struct { Prompt bool } -func NewBrowserState() *BrowserState { +func NewBrowserState(conf *Config) *BrowserState { state := &BrowserState{ History: &History{ Url: nil, Depth: 0, NavIndex: -1, }, + + Quiet: conf.Quiet, + Pager: conf.Pager, } state.CurrentTour = &state.DefaultTour return state diff --git a/tls.go b/tls.go index 22a248e..0ad56f4 100644 --- a/tls.go +++ b/tls.go @@ -1,24 +1,35 @@ package main import ( + "crypto/ed25519" + "crypto/rand" "crypto/sha256" "crypto/tls" "crypto/x509" + "crypto/x509/pkix" "encoding/hex" "errors" + "math/big" + "os" + "time" ) -func tlsConfig() *tls.Config { - return &tls.Config{ - InsecureSkipVerify: true, - VerifyConnection: tofuVerify, +func tlsConfig(state *BrowserState) *tls.Config { + if ident := state.Identities.Get(state.Url); ident != nil { + return ident } + return anonymousTLS } var tofuStore map[string]string var ErrTOFUViolation = errors.New("certificate for this domain has changed") +var anonymousTLS = &tls.Config{ + InsecureSkipVerify: true, + VerifyConnection: tofuVerify, +} + func tofuVerify(connState tls.ConnectionState) error { certhash, err := hashCert(connState.PeerCertificates[0]) if err != nil { @@ -45,3 +56,74 @@ func hashCert(cert *x509.Certificate) (string, error) { hash := sha256.Sum256(pubkeybytes) return hex.EncodeToString(hash[:]), nil } + +func createIdentity(state *BrowserState, name string) (*tls.Config, error) { + pubkey, privkey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + + rawprivkey, err := x509.MarshalPKCS8PrivateKey(privkey) + if err != nil { + return nil, err + } + + commonName := name + state.Readline.SetPrompt("Common Name [" + name + "]: ") + if line, err := state.Readline.Readline(); err != nil { + return nil, err + } else if line != "" { + commonName = line + } + + expiration := time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC) + state.Readline.SetPrompt("Expiration (yyyy-mm-dd) [9999-12-31]: ") + if line, err := state.Readline.Readline(); err != nil { + return nil, err + } else if line != "" { + expiration, err = time.ParseInLocation(time.DateOnly, line, time.UTC) + if err != nil { + return nil, err + } + } + + snLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, snLimit) + if err != nil { + return nil, err + } + + template := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{CommonName: commonName}, + NotAfter: expiration, + KeyUsage: x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + + rawcert, err := x509.CreateCertificate(rand.Reader, template, template, pubkey, privkey) + if err != nil { + return nil, err + } + + identFile, err := saveIdentity(name, rawprivkey, rawcert) + if err != nil { + return nil, err + } + + cert, err := tls.LoadX509KeyPair(identFile, identFile) + if err != nil { + _ = os.Remove(identFile) + return nil, err + } + + return identityForCert(cert), nil +} + +func identityForCert(cert tls.Certificate) *tls.Config { + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + InsecureSkipVerify: true, + VerifyConnection: tofuVerify, + } +} -- cgit v1.2.3