From 8c88c79fd958b07ac4708a0223c83ce63cf1bf9c Mon Sep 17 00:00:00 2001 From: Bill Mill Date: Wed, 15 Jun 2022 15:19:58 -0400 Subject: [PATCH] initial commit --- .gitignore | 1 + client/main.go | 131 +++++++++++++++++++++++++++++ go.mod | 5 ++ go.sum | 2 + modd.conf | 3 + server/main.go | 219 +++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 361 insertions(+) create mode 100644 .gitignore create mode 100644 client/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 modd.conf create mode 100644 server/main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98e6ef6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.db diff --git a/client/main.go b/client/main.go new file mode 100644 index 0000000..d8e16b4 --- /dev/null +++ b/client/main.go @@ -0,0 +1,131 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "errors" + "fmt" + "io/ioutil" + "net/http" + "os" + "os/user" + "path/filepath" + "time" +) + +func validKey() (ed25519.PublicKey, ed25519.PrivateKey) { + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + panic(err) + } + + // TODO: generate a new key if necessary + target, err := hex.DecodeString("ed2023") + if err != nil { + panic(err) + } + + for bytes.Compare(pub[29:32], target) != 0 { + // fmt.Printf("%x\n", pub[29:32]) + pub, priv, err = ed25519.GenerateKey(nil) + if err != nil { + panic(err) + } + } + fmt.Printf("%s\n", fmt.Sprintf("%x", pub)) + return pub, priv +} + +func fileExists(name string) bool { + if _, err := os.Stat(name); errors.Is(err, os.ErrNotExist) { + return false + } + return true +} + +func getKeys() (ed25519.PublicKey, ed25519.PrivateKey) { + user, err := user.Current() + if err != nil { + panic(err) + } + + configPath := os.Getenv("XDG_CONFIG_HOME") + if configPath == "" { + configPath = filepath.Join(user.HomeDir, ".config", "spring83") + } + + if err = os.MkdirAll(configPath, os.ModePerm); err != nil { + panic(err) + } + + pubfile := filepath.Join(configPath, "key.pub") + privfile := filepath.Join(configPath, "key.priv") + var pubkey ed25519.PublicKey + var privkey ed25519.PrivateKey + if fileExists(pubfile) && fileExists(privfile) { + pubkey, err = ioutil.ReadFile(pubfile) + if err != nil { + panic(err) + } + privkey, err = ioutil.ReadFile(privfile) + if err != nil { + panic(err) + } + } else { + fmt.Printf("Generating key, give this a minute or two\n") + pubkey, privkey = validKey() + + os.WriteFile(pubfile, pubkey, 0666) + os.WriteFile(privfile, privkey, 0600) + } + + return pubkey, privkey +} + +func main() { + pubkey, privkey := getKeys() + + // initialize http client + client := &http.Client{} + + body, err := ioutil.ReadAll(os.Stdin) + if err != nil { + panic(err) + } + + if len(body) == 0 { + panic(fmt.Errorf("input required")) + } + if len(body) > 2217 { + panic(fmt.Errorf("input body too long")) + } + + // set the HTTP method, url, and request body + url := fmt.Sprintf("http://localhost:8000/%x", pubkey) + fmt.Printf("URL: %s\n", url) + req, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(body)) + if err != nil { + panic(err) + } + + sig := ed25519.Sign(privkey, body) + fmt.Printf("Spring-83 Signature=%x\n", sig) + req.Header.Set("Authorization", fmt.Sprintf("Spring-83 Signature=%x", sig)) + + dt := time.Now().Format(time.RFC1123) + req.Header.Set("If-Unmodified-Since", dt) + + resp, err := client.Do(req) + if err != nil { + panic(err) + } + defer resp.Body.Close() + + responseBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + panic(err) + } + + fmt.Printf("%s: %s\n", resp.Status, responseBody) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4f37c40 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/llimllib/springer + +go 1.18 + +require github.com/mattn/go-sqlite3 v1.14.13 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..47ee775 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/mattn/go-sqlite3 v1.14.13 h1:1tj15ngiFfcZzii7yd82foL+ks+ouQcj8j/TPq3fk1I= +github.com/mattn/go-sqlite3 v1.14.13/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= diff --git a/modd.conf b/modd.conf new file mode 100644 index 0000000..8e0b90d --- /dev/null +++ b/modd.conf @@ -0,0 +1,3 @@ +server/main.go { + daemon: go run server/main.go +} diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..b6672d9 --- /dev/null +++ b/server/main.go @@ -0,0 +1,219 @@ +// https://github.com/robinsloan/spring-83-spec/blob/main/draft-20220609.md +package main + +import ( + "bytes" + "crypto/ed25519" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +func must(err error) { + if err != nil { + log.Fatal(err) + } +} + +func initDB() *sql.DB { + dbName := "./spring83.db" + + // if the db doesn't exist, create it + if _, err := os.Stat(dbName); errors.Is(err, os.ErrNotExist) { + log.Printf("initializing new database") + db, err := sql.Open("sqlite3", dbName) + must(err) + + initSQL := ` + CREATE TABLE boards ( + key text NOT NULL PRIMARY KEY, + board text, + expiry text + ); + ` + + _, err = db.Exec(initSQL) + if err != nil { + log.Fatal("%q: %s\n", err, initSQL) + } + return db + } + + db, err := sql.Open("sqlite3", dbName) + must(err) + return db +} + +func main() { + db := initDB() + log.Print("starting helloserver") + + server := newSpring83Server(db) + http.HandleFunc("/", server.RootHandler) + + log.Fatal(http.ListenAndServe(":8000", nil)) +} + +type Spring83Server struct { + db *sql.DB +} + +func newSpring83Server(db *sql.DB) *Spring83Server { + return &Spring83Server{ + db: db, + } +} + +func (s *Spring83Server) publishBoard(w http.ResponseWriter, r *http.Request) { + body, err := ioutil.ReadAll(r.Body) + if err != nil { + panic(err) + } + + if len(body) > 2217 { + http.Error(w, "Payload too large", http.StatusRequestEntityTooLarge) + return + } + + var mtime time.Time + if ifUnmodifiedHeader, ok := r.Header["If-Unmodified-Since"]; !ok { + http.Error(w, "Missing If-Unmodified-Since header", http.StatusBadRequest) + return + } else { + // spec says "in HTTP format", but it's not entirely clear if this matches? + if mtime, err = time.Parse(time.RFC1123, ifUnmodifiedHeader[0]); err != nil { + http.Error(w, "Invalid format for If-Unmodified-Since header", http.StatusBadRequest) + return + } + } + + // TODO : If this value (mtime) is older or equal to the timestamp of the + // server's version of the board, the server must reject the request with + // 409 Conflict. + // + // this is just here to allow the variable to exist + log.Printf("%s\n", mtime) + + var signature []byte + if authorizationHeaders, ok := r.Header["Authorization"]; !ok { + http.Error(w, "Missing Authorization header", http.StatusBadRequest) + return + } else { + parts := strings.Split(authorizationHeaders[0], " ") + if parts[0] != "Spring-83" || len(parts) < 2 { + http.Error(w, "Invalid Authorization Type", http.StatusBadRequest) + return + } + + sig := strings.Split(parts[1], "=") + if len(sig) < 1 { + http.Error(w, "Invalid Signature", http.StatusBadRequest) + return + } + + sigString := sig[1] + if len(sigString) != 128 { + http.Error(w, fmt.Sprintf("Expecting 64-bit signature %s %d", sigString, len(sigString)), http.StatusBadRequest) + return + } + + signature, err = hex.DecodeString(sigString) + if err != nil { + http.Error(w, "Unable to decode signature", http.StatusBadRequest) + return + } + } + + // Spring '83 specifies a test keypair + // Servers must not accept PUTs for this key, returning 401 Unauthorized. + // The server may also use a denylist to block certain keys, rejecting all PUTs for those keys. + denylist := []string{"fad415fbaa0339c4fd372d8287e50f67905321ccfd9c43fa4c20ac40afed1983"} + for _, key := range denylist { + if bytes.Compare(signature, []byte(key)) == 0 { + http.Error(w, "Denied", http.StatusUnauthorized) + } + } + + // the path must be a ed25519 public key of 32 bytes + fmt.Printf("%s\n", r.URL.Path[1:]) + key, err := hex.DecodeString(r.URL.Path[1:]) + if err != nil || len(key) != 32 { + http.Error(w, "Invalid key", http.StatusBadRequest) + return + } + + // If the current four-digit year is YYYY, and the + // previous four-digit year is YYYX, the server must + // only accept PUTs for keys that end with the four + // digits YYYY or YYYX, preceded in turn by the two hex + // digits "ed". This is the years-of-use requirement. + // + // The server must reject other keys with 400 Bad + // Request. + keyStr := fmt.Sprintf("%x", key) + last4 := string(keyStr[60:64]) + if keyStr[58:60] != "ed" || + (last4 != time.Now().Format("2006") && + last4 != time.Now().AddDate(1, 0, 0).Format("2006")) { + log.Printf("%s %s %s", keyStr[58:60] == "ed", last4 == time.Now().Format("2006"), time.Now().Format("2006")) + http.Error(w, "Signature must end with edYYYY", http.StatusBadRequest) + return + } + + // at this point, we should have met all the preconditions prior to the + // cryptographic check. By the spec, we should perform all + // non-cryptographic checks first. + if !ed25519.Verify(key, body, signature) { + http.Error(w, "Invalid signature", http.StatusBadRequest) + return + } + + // TODO: + // Additionally, if the server doesn't have any board + // stored for , then it must apply another check. The + // key, interpreted as a 256-bit number, must be less than + // a threshold defined by the server's difficulty factor: + // + // MAX_SIG = (2**256 - 1) key_threshold = MAX_SIG * ( 1.0 - + // difficulty_factor) + // + // This check is not applied to keys for which the server + // already has a board stored. You can read more about the + // difficulty factor later in this document. + + expiry := time.Now().AddDate(0, 0, 7).Format(time.RFC3339) + _, err = s.db.Exec(` + INSERT INTO boards (key, board, expiry) + values(?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + board=?, + expiry=? + `, keyStr, body, expiry, body, expiry) + + if err != nil { + log.Printf("%s", err) + http.Error(w, "Server error", http.StatusInternalServerError) + } +} + +func (s *Spring83Server) showBoard(w http.ResponseWriter, r *http.Request) { +} + +func (s *Spring83Server) RootHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "PUT" { + s.publishBoard(w, r) + } else if r.Method == "GET" { + s.showBoard(w, r) + } else { + http.Error(w, "Invalid method", http.StatusBadRequest) + } +}