identity management and use

This commit is contained in:
tjp 2024-01-08 11:10:24 -07:00
parent 230933ee0e
commit a90327bcc0
8 changed files with 575 additions and 24 deletions

View File

@ -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

View File

@ -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)
}

141
files.go
View File

@ -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)
}

34
help.go
View File

@ -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]
------

205
identity.go Normal file
View File

@ -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)
}

13
main.go
View File

@ -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)
}

View File

@ -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

90
tls.go
View File

@ -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,
}
}