mirror of
https://github.com/coalaura/up.git
synced 2025-07-17 21:44:35 +00:00
initial commit
This commit is contained in:
21
client/keys.go
Normal file
21
client/keys.go
Normal file
@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func LoadPrivateKey(path string) (ssh.Signer, error) {
|
||||
key, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return signer, nil
|
||||
}
|
103
client/main.go
Normal file
103
client/main.go
Normal file
@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
func main() {
|
||||
app := &cli.Command{
|
||||
Name: "up",
|
||||
Usage: "UP client",
|
||||
Version: Version,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "key",
|
||||
Aliases: []string{"k"},
|
||||
Usage: "private key file for authentication",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "file",
|
||||
Aliases: []string{"f"},
|
||||
Usage: "file to upload",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "target",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "target to upload to",
|
||||
},
|
||||
},
|
||||
Action: run,
|
||||
EnableShellCompletion: true,
|
||||
UseShortOptionHandling: true,
|
||||
Suggest: true,
|
||||
}
|
||||
|
||||
if err := app.Run(context.Background(), os.Args); err != nil {
|
||||
fmt.Printf("fatal: %v\n", err)
|
||||
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(_ context.Context, cmd *cli.Command) error {
|
||||
kPath := cmd.String("key")
|
||||
if kPath == "" {
|
||||
return errors.New("missing private key")
|
||||
}
|
||||
|
||||
fPath := cmd.String("file")
|
||||
if fPath == "" {
|
||||
return errors.New("missing file")
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(fPath, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file: %v", err)
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
target := cmd.String("target")
|
||||
if target == "" {
|
||||
return errors.New("missing target")
|
||||
}
|
||||
|
||||
if colon := strings.Index(target, ":"); colon != -1 {
|
||||
target = fmt.Sprintf("http://%s", target)
|
||||
} else {
|
||||
target = fmt.Sprintf("https://%s", target)
|
||||
}
|
||||
|
||||
private, err := LoadPrivateKey(kPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load key: %v", err)
|
||||
}
|
||||
|
||||
public := base64.StdEncoding.EncodeToString(private.PublicKey().Marshal())
|
||||
|
||||
log.Println("Requesting challenge...")
|
||||
|
||||
challenge, err := RequestChallenge(target, public)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("Completing challenge...")
|
||||
|
||||
response, err := CompleteChallenge(target, public, private, challenge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendFile(target, response.Token, file)
|
||||
}
|
125
client/protocol.go
Normal file
125
client/protocol.go
Normal file
@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/coalaura/up/internal"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func RequestChallenge(target, public string) (*internal.AuthChallenge, error) {
|
||||
request, err := json.Marshal(internal.AuthRequest{
|
||||
Public: public,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %v", err)
|
||||
}
|
||||
|
||||
response, err := http.Post(fmt.Sprintf("%s/request", target), "application/json", bytes.NewReader(request))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %v", err)
|
||||
}
|
||||
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, errors.New(response.Status)
|
||||
}
|
||||
|
||||
var challenge internal.AuthChallenge
|
||||
|
||||
if err := json.NewDecoder(response.Body).Decode(&challenge); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
return &challenge, nil
|
||||
}
|
||||
|
||||
func CompleteChallenge(target, public string, private ssh.Signer, challenge *internal.AuthChallenge) (*internal.AuthResponse, error) {
|
||||
rawChallenge, err := base64.StdEncoding.DecodeString(challenge.Challenge)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode challenge: %v", err)
|
||||
}
|
||||
|
||||
signature, err := private.Sign(rand.Reader, rawChallenge)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign challenge: %v", err)
|
||||
}
|
||||
|
||||
request, err := json.Marshal(internal.AuthResponse{
|
||||
Token: challenge.Token,
|
||||
Public: public,
|
||||
Format: signature.Format,
|
||||
Signature: base64.StdEncoding.EncodeToString(signature.Blob),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %v", err)
|
||||
}
|
||||
|
||||
response, err := http.Post(fmt.Sprintf("%s/complete", target), "application/json", bytes.NewReader(request))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %v", err)
|
||||
}
|
||||
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, errors.New(response.Status)
|
||||
}
|
||||
|
||||
var result internal.AuthResponse
|
||||
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func SendFile(target, token string, file *os.File) error {
|
||||
var buf bytes.Buffer
|
||||
|
||||
writer := multipart.NewWriter(&buf)
|
||||
|
||||
part, err := writer.CreateFormFile("file", filepath.Base(file.Name()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create form file: %v", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(part, file); err != nil {
|
||||
return fmt.Errorf("failed to copy file: %v", err)
|
||||
}
|
||||
|
||||
writer.Close()
|
||||
|
||||
request, err := http.NewRequest("POST", fmt.Sprintf("%s/receive", target), &buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
request.Header.Set("Authorization", token)
|
||||
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send request: %v", err)
|
||||
}
|
||||
|
||||
response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return errors.New(response.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
5
example.key
Normal file
5
example.key
Normal file
@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHgCAQEEIQDg6JmpMO1i5nVHdHHfdJuOgDMMRqx4BynOWt68YidBjKAKBggqhkjO
|
||||
PQMBB6FEA0IABI0rYdm3nt2/etmeJFS6+nyJAB9egNpFBClppW0nNjQ5nfok0J16
|
||||
GBOJDHoF/XpFv6z9BnXOlkcLgCPuMdXhFbI=
|
||||
-----END EC PRIVATE KEY-----
|
BIN
example.webp
Normal file
BIN
example.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 249 KiB |
22
go.mod
Normal file
22
go.mod
Normal file
@ -0,0 +1,22 @@
|
||||
module github.com/coalaura/up
|
||||
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
github.com/coalaura/logger v1.4.4
|
||||
github.com/fasthttp/router v1.5.4
|
||||
github.com/urfave/cli/v3 v3.3.8
|
||||
github.com/valyala/fasthttp v1.62.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/gookit/color v1.5.4 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/term v0.32.0 // indirect
|
||||
)
|
38
go.sum
Normal file
38
go.sum
Normal file
@ -0,0 +1,38 @@
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/coalaura/logger v1.4.4 h1:x3L+hsIoLxv1r7LpmaYimydas6qRb4SeDI3/qFFmOMY=
|
||||
github.com/coalaura/logger v1.4.4/go.mod h1:/BHeRXN2FNG5NHaE7nVHR2eziLSdgyzPffeCmiwXVRo=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fasthttp/router v1.5.4 h1:oxdThbBwQgsDIYZ3wR1IavsNl6ZS9WdjKukeMikOnC8=
|
||||
github.com/fasthttp/router v1.5.4/go.mod h1:3/hysWq6cky7dTfzaaEPZGdptwjwx0qzTgFCKEWRjgc=
|
||||
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
|
||||
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc=
|
||||
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/urfave/cli/v3 v3.3.8 h1:BzolUExliMdet9NlJ/u4m5vHSotJ3PzEqSAZ1oPMa/E=
|
||||
github.com/urfave/cli/v3 v3.3.8/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0=
|
||||
github.com/valyala/fasthttp v1.62.0/go.mod h1:FCINgr4GKdKqV8Q0xv8b+UxPV+H/O5nNFo3D+r54Htg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
33
internal/challenge.go
Normal file
33
internal/challenge.go
Normal file
@ -0,0 +1,33 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
func FreshChallenge() (*AuthChallenge, []byte, error) {
|
||||
challenge, err := random(64)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
token, err := random(64)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return &AuthChallenge{
|
||||
Token: base64.StdEncoding.EncodeToString(token),
|
||||
Challenge: base64.StdEncoding.EncodeToString(challenge),
|
||||
}, challenge, nil
|
||||
}
|
||||
|
||||
func random(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
38
internal/types.go
Normal file
38
internal/types.go
Normal file
@ -0,0 +1,38 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type ChallengeEntry struct {
|
||||
Challenge []byte
|
||||
PublicKey ssh.PublicKey
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
type SessionEntry struct {
|
||||
PublicKey ssh.PublicKey
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
type AuthRequest struct {
|
||||
Public string `json:"public"`
|
||||
}
|
||||
|
||||
type AuthChallenge struct {
|
||||
Token string `json:"token"`
|
||||
Challenge string `json:"challenge"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
Public string `json:"public"`
|
||||
Format string `json:"format"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
type AuthResult struct {
|
||||
Token string `json:"token"`
|
||||
}
|
12
server/connection.go
Normal file
12
server/connection.go
Normal file
@ -0,0 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
func HandleConnection(conn net.Conn) error {
|
||||
time.Sleep(10 * time.Second)
|
||||
|
||||
return nil
|
||||
}
|
57
server/keys.go
Normal file
57
server/keys.go
Normal file
@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func GetAuthorizedKeysPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(home, ".ssh", "authorized_keys"), nil
|
||||
}
|
||||
|
||||
func LoadAuthorizedKeys() (map[string]ssh.PublicKey, error) {
|
||||
path, err := GetAuthorizedKeysPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keys := make(map[string]ssh.PublicKey)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for len(data) > 0 {
|
||||
pubKey, _, _, rest, err := ssh.ParseAuthorizedKey(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keys[string(pubKey.Marshal())] = pubKey
|
||||
|
||||
data = rest
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func RandomToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(b), nil
|
||||
}
|
38
server/main.go
Normal file
38
server/main.go
Normal file
@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/coalaura/logger"
|
||||
"github.com/fasthttp/router"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
var (
|
||||
log = logger.New().WithOptions(logger.Options{
|
||||
NoLevel: true,
|
||||
})
|
||||
|
||||
challenges sync.Map
|
||||
sessions sync.Map
|
||||
)
|
||||
|
||||
func main() {
|
||||
authorized, err := LoadAuthorizedKeys()
|
||||
log.MustPanic(err)
|
||||
|
||||
r := router.New()
|
||||
|
||||
r.POST("/request", func(ctx *fasthttp.RequestCtx) {
|
||||
HandleChallengeRequest(ctx, authorized)
|
||||
})
|
||||
|
||||
r.POST("/complete", func(ctx *fasthttp.RequestCtx) {
|
||||
HandleCompleteRequest(ctx, authorized)
|
||||
})
|
||||
|
||||
r.POST("/receive", HandleReceiveRequest)
|
||||
|
||||
log.Println("Listening on :7966")
|
||||
fasthttp.ListenAndServe(":7966", r.Handler)
|
||||
}
|
292
server/protocol.go
Normal file
292
server/protocol.go
Normal file
@ -0,0 +1,292 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/coalaura/up/internal"
|
||||
"github.com/valyala/fasthttp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
var (
|
||||
SignatureFormats = map[string]bool{
|
||||
"ssh-ed25519": true,
|
||||
"ssh-rsa": true,
|
||||
"rsa-sha2-256": true,
|
||||
"rsa-sha2-512": true,
|
||||
"ecdsa-sha2-nistp256": true,
|
||||
"ecdsa-sha2-nistp384": true,
|
||||
"ecdsa-sha2-nistp521": true,
|
||||
}
|
||||
)
|
||||
|
||||
func IsSignatureFormatValid(format string) bool {
|
||||
return SignatureFormats[format]
|
||||
}
|
||||
|
||||
func HandleChallengeRequest(ctx *fasthttp.RequestCtx, authorized map[string]ssh.PublicKey) {
|
||||
var request internal.AuthRequest
|
||||
|
||||
if err := json.Unmarshal(ctx.PostBody(), &request); err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
|
||||
log.Warning("request: failed to decode request")
|
||||
log.WarningE(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
public, err := DecodeAndAuthorizePublicKey(request.Public, authorized)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
|
||||
log.Warning("request: failed to parse/authorize public key")
|
||||
log.WarningE(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
challenge, raw, err := internal.FreshChallenge()
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
|
||||
log.Warning("request: failed to generate challenge")
|
||||
log.WarningE(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
challenges.Store(challenge.Token, internal.ChallengeEntry{
|
||||
Challenge: raw,
|
||||
PublicKey: public,
|
||||
Expires: time.Now().Add(20 * time.Second),
|
||||
})
|
||||
|
||||
log.Println("new auth request")
|
||||
|
||||
ctx.SetContentType("application/json")
|
||||
json.NewEncoder(ctx).Encode(challenge)
|
||||
}
|
||||
|
||||
func HandleCompleteRequest(ctx *fasthttp.RequestCtx, authorized map[string]ssh.PublicKey) {
|
||||
var response internal.AuthResponse
|
||||
|
||||
if err := json.Unmarshal(ctx.PostBody(), &response); err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
|
||||
log.Warning("complete: failed to decode response")
|
||||
log.WarningE(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
public, err := DecodeAndAuthorizePublicKey(response.Public, authorized)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
|
||||
log.Warning("complete: failed to parse/authorize public key")
|
||||
log.WarningE(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
entry, ok := challenges.LoadAndDelete(response.Token)
|
||||
if !ok {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
|
||||
log.Warning("complete: invalid challenge token")
|
||||
log.WarningE(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
challenge := entry.(internal.ChallengeEntry)
|
||||
|
||||
if time.Now().After(challenge.Expires) {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
|
||||
log.Warning("complete: challenge expired")
|
||||
log.WarningE(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
publicA := public.Marshal()
|
||||
publicB := challenge.PublicKey.Marshal()
|
||||
|
||||
if !bytes.Equal(publicA, publicB) {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
|
||||
log.Warning("complete: incorrect public key")
|
||||
log.WarningE(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !IsSignatureFormatValid(response.Format) {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
|
||||
log.Warning("complete: unsupported signature format")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
signature, err := base64.StdEncoding.DecodeString(response.Signature)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
|
||||
log.Warning("complete: failed to decode signature")
|
||||
log.WarningE(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sig := &ssh.Signature{
|
||||
Format: response.Format,
|
||||
Blob: signature,
|
||||
}
|
||||
|
||||
if err = public.Verify(challenge.Challenge, sig); err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
|
||||
log.Warning("complete: failed to verify signature")
|
||||
log.WarningE(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
token, err := RandomToken(64)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
|
||||
log.Warning("complete: failed to create token")
|
||||
log.WarningE(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sessions.Store(token, internal.SessionEntry{
|
||||
PublicKey: public,
|
||||
Expires: time.Now().Add(5 * time.Minute),
|
||||
})
|
||||
|
||||
log.Println("auth completed")
|
||||
|
||||
ctx.SetContentType("application/json")
|
||||
json.NewEncoder(ctx).Encode(internal.AuthResult{
|
||||
Token: token,
|
||||
})
|
||||
}
|
||||
|
||||
func HandleReceiveRequest(ctx *fasthttp.RequestCtx) {
|
||||
token := string(ctx.Request.Header.Peek("Authorization"))
|
||||
if token == "" {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
|
||||
log.Warning("receive: missing token")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
entry, ok := sessions.LoadAndDelete(token)
|
||||
if !ok {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
|
||||
log.Warning("receive: invalid token")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
session := entry.(internal.SessionEntry)
|
||||
|
||||
if time.Now().After(session.Expires) {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
|
||||
log.Warning("receive: session expired")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
form, err := ctx.MultipartForm()
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
|
||||
log.Warning("receive: failed to parse multipart form")
|
||||
log.WarningE(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
files := form.File["file"]
|
||||
if len(files) == 0 {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
|
||||
log.Warning("receive: no files received")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
header := files[0]
|
||||
name := filepath.Base(header.Filename)
|
||||
|
||||
source, err := header.Open()
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
|
||||
log.Warning("receive: failed to open sent file")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer source.Close()
|
||||
|
||||
if _, err := os.Stat("files"); os.IsNotExist(err) {
|
||||
os.Mkdir("files", 0755)
|
||||
}
|
||||
|
||||
target, err := os.OpenFile(filepath.Join("files", name), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
|
||||
log.Warning("receive: failed to open target file")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer target.Close()
|
||||
|
||||
if _, err := io.Copy(target, source); err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
|
||||
log.Warning("receive: failed to copy sent file")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetStatusCode(fasthttp.StatusOK)
|
||||
}
|
||||
|
||||
func DecodeAndAuthorizePublicKey(public string, authorized map[string]ssh.PublicKey) (ssh.PublicKey, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(public)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := ssh.ParsePublicKey(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, ok := authorized[string(key.Marshal())]; !ok {
|
||||
return nil, errors.New("unauthorized key")
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
Reference in New Issue
Block a user