diff options
| -rw-r--r-- | go.mod | 4 | ||||
| -rw-r--r-- | go.sum | 4 | ||||
| -rw-r--r-- | internal/hole/hole.go | 25 | ||||
| -rw-r--r-- | internal/hole/wordlist.txt (renamed from internal/wordlist/wordlist.txt) | 0 | ||||
| -rw-r--r-- | internal/pwhash/pwhash.go | 18 | ||||
| -rw-r--r-- | internal/wordlist/wordlist.go | 11 | ||||
| -rw-r--r-- | roseh.moe.go | 26 | ||||
| -rw-r--r-- | tools/finditer/finditer.go | 43 | ||||
| -rw-r--r-- | tools/hashpw/hashpw.go | 34 | ||||
| -rw-r--r-- | tools/hole/hole.go | 94 |
10 files changed, 204 insertions, 55 deletions
@@ -1,3 +1,7 @@ module gitlab.com/rhogenson/roseh.moe go 1.24.0 + +require golang.org/x/term v0.35.0 + +require golang.org/x/sys v0.36.0 // indirect @@ -0,0 +1,4 @@ +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= diff --git a/internal/hole/hole.go b/internal/hole/hole.go new file mode 100644 index 0000000..4824459 --- /dev/null +++ b/internal/hole/hole.go @@ -0,0 +1,25 @@ +package hole + +import ( + "crypto/rand" + _ "embed" + "encoding/binary" + "strings" +) + +var ( + //go:embed wordlist.txt + wordListString string + wordList = strings.Split(strings.TrimSuffix(wordListString, "\n"), "\n") +) + +func New() string { + const nWords = 10 + buf := make([]byte, 2*nWords) + rand.Read(buf) + words := make([]string, nWords) + for i := range words { + words[i] = wordList[binary.NativeEndian.Uint16(buf[2*i:])&0x1fff] + } + return strings.Join(words, "-") +} diff --git a/internal/wordlist/wordlist.txt b/internal/hole/wordlist.txt index 5e304ce..5e304ce 100644 --- a/internal/wordlist/wordlist.txt +++ b/internal/hole/wordlist.txt diff --git a/internal/pwhash/pwhash.go b/internal/pwhash/pwhash.go new file mode 100644 index 0000000..576d052 --- /dev/null +++ b/internal/pwhash/pwhash.go @@ -0,0 +1,18 @@ +package pwhash + +import ( + "crypto/pbkdf2" + "crypto/sha512" +) + +const SaltSize = 8 + +const defaultIter = 12059332 // from tools/finditer + +func HashIter(password string, salt []byte, iter int) ([]byte, error) { + return pbkdf2.Key(sha512.New, password, salt, iter, 32) +} + +func Hash(password string, salt []byte) ([]byte, error) { + return HashIter(password, salt, defaultIter) +} diff --git a/internal/wordlist/wordlist.go b/internal/wordlist/wordlist.go deleted file mode 100644 index de929bc..0000000 --- a/internal/wordlist/wordlist.go +++ /dev/null @@ -1,11 +0,0 @@ -package wordlist - -import ( - _ "embed" - "strings" -) - -//go:embed wordlist.txt -var wordList string - -var Words = strings.Split(strings.TrimSuffix(wordList, "\n"), "\n") diff --git a/roseh.moe.go b/roseh.moe.go index 140b1de..d3e96bf 100644 --- a/roseh.moe.go +++ b/roseh.moe.go @@ -8,7 +8,6 @@ import ( "crypto/subtle" "embed" "encoding/base64" - "encoding/binary" "encoding/hex" "flag" "fmt" @@ -21,7 +20,8 @@ import ( "sync" "time" - "gitlab.com/rhogenson/roseh.moe/internal/wordlist" + "gitlab.com/rhogenson/roseh.moe/internal/hole" + "gitlab.com/rhogenson/roseh.moe/internal/pwhash" ) var ( @@ -131,19 +131,8 @@ type wormholeTemplateArgs struct { Hole string } -func makeHole() string { - const nWords = 10 - buf := make([]byte, 2*nWords) - rand.Read(buf) - words := make([]string, nWords) - for i := range words { - words[i] = wordlist.Words[binary.NativeEndian.Uint16(buf[2*i:])&0x1fff] - } - return strings.Join(words, "-") -} - func wormhole(w http.ResponseWriter, r *http.Request) { - if err := wormholeTemplate.Execute(w, wormholeTemplateArgs{Self: *selfURL, Hole: makeHole()}); err != nil { + if err := wormholeTemplate.Execute(w, wormholeTemplateArgs{Self: *selfURL, Hole: hole.New()}); err != nil { log.Printf("Warning: wormhole: %s", err) } } @@ -358,6 +347,12 @@ func checkCSRFToken(r *http.Request) error { return nil } +func checkPassword(password string) bool { + expectedHash, salt := notepadPassword[:len(notepadPassword)-pwhash.SaltSize], notepadPassword[len(notepadPassword)-pwhash.SaltSize:] + hash, err := pwhash.Hash(password, salt) + return err == nil && subtle.ConstantTimeCompare(hash, expectedHash) != 0 +} + var ( //go:embed templates/login.html.template loginTemplateString string @@ -380,8 +375,7 @@ func login(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad CSRF token", http.StatusBadRequest) return } - hash := sha256.Sum256([]byte(r.FormValue("password"))) - if subtle.ConstantTimeCompare(notepadPassword, hash[:]) == 0 { + if !checkPassword(r.FormValue("password")) { if err := loginTemplate.Execute(w, loginTemplateArgs{ Error: true, CSRFToken: r.FormValue("csrf-token"), diff --git a/tools/finditer/finditer.go b/tools/finditer/finditer.go new file mode 100644 index 0000000..fbd0363 --- /dev/null +++ b/tools/finditer/finditer.go @@ -0,0 +1,43 @@ +package main + +import ( + "encoding/hex" + "fmt" + "sort" + "testing" + "time" + + "gitlab.com/rhogenson/roseh.moe/internal/pwhash" +) + +func mustHex(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return b +} + +const password = "emcee polio cardiac disclose superglue clapper cruelness stonework tingly unarmored" + +var salt = mustHex("a0aa6971f38827e5") + +var iterations int + +func BenchmarkHashIter(b *testing.B) { + for b.Loop() { + pwhash.HashIter(password, salt, iterations) + } +} + +func main() { + const targetDuration = 5 * time.Second + for iterations = 8192; time.Duration(testing.Benchmark(BenchmarkHashIter).NsPerOp()) < targetDuration; iterations *= 2 { + } + lo := iterations / 2 + hi := iterations + fmt.Println(lo + sort.Search(hi-lo, func(i int) bool { + iterations = lo + i + return time.Duration(testing.Benchmark(BenchmarkHashIter).NsPerOp()) > targetDuration + })) +} diff --git a/tools/hashpw/hashpw.go b/tools/hashpw/hashpw.go new file mode 100644 index 0000000..c75fa2e --- /dev/null +++ b/tools/hashpw/hashpw.go @@ -0,0 +1,34 @@ +package main + +import ( + "crypto/rand" + "fmt" + "os" + + "gitlab.com/rhogenson/roseh.moe/internal/pwhash" + "golang.org/x/term" +) + +func hashpw() error { + fmt.Fprint(os.Stderr, "Enter password: ") + password, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return err + } + salt := make([]byte, pwhash.SaltSize) + rand.Read(salt) + hash, err := pwhash.Hash(string(password), salt) + if err != nil { + return err + } + fmt.Printf("%x%x\n", hash, salt) + return nil +} + +func main() { + if err := hashpw(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/tools/hole/hole.go b/tools/hole/hole.go index d701c40..c6946f0 100644 --- a/tools/hole/hole.go +++ b/tools/hole/hole.go @@ -1,36 +1,54 @@ +// The hole command is a CLI uploader tool for the roseh.moe wormhole. +// +// # Magic wormhole +// +// The name is inspired by the original "magic wormhole" library. The goal is to +// get a file from one computer to another without a USB stick, Including cases +// where scp might not be available. This includes transferring files to your +// phone, for example, although a USB cord might still be more convenient when +// moving many files. It should also work over restrictive firewalls. You should +// be able to use the wormhole as long as you can access http://roseh.moe in +// a browser. +// +// # Usage example +// +// The following shows a typical interaction with the tool +// +// $ hole file.pdf +// https://roseh.moe/wormhole/dv-32-ethos-aztec-shako-spoils-2y-dwarf-sand-any +// Waiting for upload... +// <tool hangs, waiting to upload the file> +// +// Then, on another machine: +// +// $ curl https://roseh.moe/wormhole/dv-32-ethos-aztec-shako-spoils-2y-dwarf-sand-any > file.pdf +// +// # Web interface +// +// There's also a web interface available for the wormhole, available at +// https://roseh.moe/wormhole. package main import ( - "crypto/rand" - "encoding/binary" "flag" "fmt" "io" "mime/multipart" "net/http" "os" - "strings" - "gitlab.com/rhogenson/roseh.moe/internal/wordlist" + "gitlab.com/rhogenson/roseh.moe/internal/hole" ) var url = flag.String("url", "https://roseh.moe", "server URL") -func makeHole() string { - const nWords = 10 - buf := make([]byte, 2*nWords) - rand.Read(buf) - words := make([]string, nWords) - for i := range words { - words[i] = wordlist.Words[binary.NativeEndian.Uint16(buf[2*i:])&0x1fff] - } - return strings.Join(words, "-") -} - -func hole() error { +func upload() error { args := flag.Args() - if len(args) != 1 { - return fmt.Errorf("usage error") + if len(args) == 0 { + return fmt.Errorf("usage error: missing filename") + } + if len(args) > 1 { + return fmt.Errorf("usage error: too many positional arguments") } fileName := args[0] f, err := os.Open(fileName) @@ -38,18 +56,19 @@ func hole() error { return err } defer f.Close() - hole := makeHole() + hole := hole.New() fmt.Println(*url + "/wormhole/" + hole) + fmt.Fprintln(os.Stderr, "Waiting for upload...") resp, err := http.Get(*url + "/wormhole/" + hole + "/ready") if err != nil { - return err + return fmt.Errorf("check wormhole ready: %s", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("%s", resp.Status) + return fmt.Errorf("check wormhole ready: status: %s", resp.Status) } if _, err := io.Copy(io.Discard, resp.Body); err != nil { - return err + return fmt.Errorf("check wormhole ready: read response: %s", err) } pipeReader, pipeWriter := io.Pipe() multipartWriter := multipart.NewWriter(pipeWriter) @@ -57,15 +76,15 @@ func hole() error { defer pipeWriter.Close() part, err := multipartWriter.CreateFormFile("file", fileName) if err != nil { - fmt.Fprintln(os.Stderr, err) + pipeWriter.CloseWithError(fmt.Errorf("create multipart reader: %s", err)) return } if _, err := io.Copy(part, f); err != nil { - fmt.Fprintln(os.Stderr, err) + pipeWriter.CloseWithError(fmt.Errorf("write body: %s", err)) return } if err := multipartWriter.Close(); err != nil { - fmt.Fprintln(os.Stderr, err) + pipeWriter.CloseWithError(fmt.Errorf("write trailer: %s", err)) return } }() @@ -75,7 +94,7 @@ func hole() error { } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("%s", resp.Status) + return fmt.Errorf("upload: status: %s", resp.Status) } io.Copy(os.Stderr, resp.Body) fmt.Fprintln(os.Stderr) @@ -83,9 +102,28 @@ func hole() error { } func main() { + flag.Usage = func() { + fmt.Fprint(os.Stderr, `Usage: hole FILE +Create a one-time wormhole that allows a specific client to download a +file. Prints the URL to download the file and then hangs until the file +is downloaded. + +Example: + $ hole file.pdf + https://roseh.moe/wormhole/dv-32-ethos-aztec-shako-spoils-2y-dwarf-sand-any + Waiting for upload... + <tool hangs, waiting to upload the file> + +Then, on another machine: + $ curl https://roseh.moe/wormhole/dv-32-ethos-aztec-shako-spoils-2y-dwarf-sand-any > file.pdf + +Flags: +`) + flag.PrintDefaults() + } flag.Parse() - if err := hole(); err != nil { - fmt.Fprintln(os.Stderr, err) + if err := upload(); err != nil { + fmt.Fprintf(os.Stderr, "hole: %s\n", err) os.Exit(1) } } |
