summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortjp <tjp@ctrl-c.club>2024-01-08 11:10:24 -0700
committertjp <tjp@ctrl-c.club>2024-01-08 11:10:24 -0700
commita90327bcc0f46171e30a4a549fb5b44f8e91e303 (patch)
tree8595b256c8e9fa2232525199c51021e0efbebac4
parent230933ee0e4bce6ddf25e0816fff0bd30e3c8864 (diff)
identity management and use
-rw-r--r--actions.go30
-rw-r--r--command.go79
-rw-r--r--files.go141
-rw-r--r--help.go34
-rw-r--r--identity.go205
-rw-r--r--main.go13
-rw-r--r--state.go7
-rw-r--r--tls.go90
8 files changed, 575 insertions, 24 deletions
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,
+ }
+}