package server import ( "bytes" "crypto" "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/binary" "encoding/json" "errors" "fmt" "math/big" "net" "net/http" "net/url" "strconv" "strings" "sync" "time" ) // Passkeys (WebAuthn): an admin signs in with a key held by the device - a // fingerprint, the phone's lock, a hardware key - instead of a password. Once // an admin has a key, the password no longer signs them in. // // Attestation is not checked ("none"): the panel trusts any authenticator, // it only needs the public key and, at sign-in, a signature made with it. var b64u = base64.RawURLEncoding // challenges are the ones handed out and not yet used, a few minutes each. type challenges struct { mu sync.Mutex m map[string]challenge } type challenge struct { adminID int64 // the admin registering a key; 0 for a sign-in expires time.Time } func (c *challenges) issue(adminID int64) string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { panic("crypto/rand failed: " + err.Error()) } id := b64u.EncodeToString(b) c.mu.Lock() defer c.mu.Unlock() if c.m == nil { c.m = map[string]challenge{} } for k, v := range c.m { if time.Now().After(v.expires) { delete(c.m, k) } } c.m[id] = challenge{adminID: adminID, expires: time.Now().Add(5 * time.Minute)} return id } // take uses up a challenge: true when it was issued, for this admin, and is // still fresh. func (c *challenges) take(id string, adminID int64) bool { c.mu.Lock() defer c.mu.Unlock() ch, ok := c.m[id] delete(c.m, id) return ok && ch.adminID == adminID && time.Now().Before(ch.expires) } // rpID is the site the keys belong to: the host the browser is on. func rpID(r *http.Request) string { host := r.Host if h, _, err := net.SplitHostPort(host); err == nil { host = h } return host } // checkClientData verifies what the browser signed over: the kind of // ceremony, our challenge and our origin. A proxy in front may speak plain // http to us, so the origin is matched by host, and must be https unless the // panel is on this machine. func checkClientData(r *http.Request, raw []byte, typ string, adminID int64, ch *challenges) error { var cd struct { Type string `json:"type"` Challenge string `json:"challenge"` Origin string `json:"origin"` } if err := json.Unmarshal(raw, &cd); err != nil { return errors.New("ответ ключа не читается") } if cd.Type != typ { return errors.New("ответ ключа не того вида") } if !ch.take(cd.Challenge, adminID) { return errors.New("запрос устарел - попробуйте ещё раз") } u, err := url.Parse(cd.Origin) if err != nil || u.Host != r.Host { return errors.New("ключ подписал запрос для другого сайта") } local := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "::1" if u.Scheme != "https" && !local { return errors.New("ключи доступа работают только по https") } return nil } // authData is the authenticator's part: rpIdHash, flags, counter, then the // new credential at registration. type authData struct { rpIDHash []byte flags byte signCount uint32 credID []byte coseKey []byte } func parseAuthData(b []byte, wantCred bool) (*authData, error) { if len(b) < 37 { return nil, errors.New("данные ключа слишком короткие") } ad := &authData{rpIDHash: b[:32], flags: b[32], signCount: binary.BigEndian.Uint32(b[33:37])} if !wantCred { return ad, nil } if ad.flags&0x40 == 0 || len(b) < 37+18 { return nil, errors.New("ключ не прислал себя") } n := int(binary.BigEndian.Uint16(b[53:55])) if len(b) < 55+n { return nil, errors.New("данные ключа обрезаны") } ad.credID = b[55 : 55+n] ad.coseKey = b[55+n:] return ad, nil } func (ad *authData) check(r *http.Request) error { sum := sha256.Sum256([]byte(rpID(r))) if !bytes.Equal(ad.rpIDHash, sum[:]) { return errors.New("ключ создан для другого сайта") } if ad.flags&0x01 == 0 { return errors.New("ключ не подтвердил присутствие") } return nil } // coseToPKIX turns a COSE public key (ES256, RS256 or EdDSA) into PKIX DER. func coseToPKIX(raw []byte) ([]byte, error) { v, _, err := cborDecode(raw) if err != nil { return nil, err } m, ok := v.(map[any]any) if !ok { return nil, errors.New("ключ не в формате COSE") } num := func(k int64) int64 { n, _ := m[k].(int64); return n } bin := func(k int64) []byte { b, _ := m[k].([]byte); return b } var pub any switch num(1) { // kty case 2: // EC2 x, y := bin(-2), bin(-3) if num(-1) != 1 || len(x) != 32 || len(y) != 32 { return nil, errors.New("поддерживается только кривая P-256") } k := &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y)} if !k.Curve.IsOnCurve(k.X, k.Y) { return nil, errors.New("ключ не на кривой") } pub = k case 3: // RSA n, e := bin(-1), bin(-2) if len(n) < 256 || len(e) == 0 || len(e) > 4 { return nil, errors.New("неподходящий ключ RSA") } pub = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())} case 1: // OKP x := bin(-2) if num(-1) != 6 || len(x) != ed25519.PublicKeySize { return nil, errors.New("поддерживается только Ed25519") } pub = ed25519.PublicKey(x) default: return nil, errors.New("неизвестный вид ключа") } return x509.MarshalPKIXPublicKey(pub) } func verifySig(der, data, sig []byte) bool { pub, err := x509.ParsePKIXPublicKey(der) if err != nil { return false } sum := sha256.Sum256(data) switch k := pub.(type) { case *ecdsa.PublicKey: return ecdsa.VerifyASN1(k, sum[:], sig) case *rsa.PublicKey: return rsa.VerifyPKCS1v15(k, crypto.SHA256, sum[:], sig) == nil case ed25519.PublicKey: return ed25519.Verify(k, data, sig) } return false } // ---------- CBOR, as much as WebAuthn needs ---------- func cborDecode(b []byte) (any, []byte, error) { r := &cborReader{b: b} v, err := r.item(0) return v, r.b[r.i:], err } type cborReader struct { b []byte i int } var errCBOR = errors.New("данные ключа повреждены") func (r *cborReader) arg(info byte) (uint64, error) { size := map[byte]int{24: 1, 25: 2, 26: 4, 27: 8} if info < 24 { return uint64(info), nil } n, ok := size[info] if !ok || r.i+n > len(r.b) { return 0, errCBOR } var v uint64 for _, c := range r.b[r.i : r.i+n] { v = v<<8 | uint64(c) } r.i += n return v, nil } func (r *cborReader) take(n uint64) ([]byte, error) { if n > uint64(len(r.b)-r.i) { return nil, errCBOR } out := r.b[r.i : r.i+int(n)] r.i += int(n) return out, nil } func (r *cborReader) item(depth int) (any, error) { if r.i >= len(r.b) || depth > 16 { return nil, errCBOR } ib := r.b[r.i] r.i++ major, info := ib>>5, ib&31 if major == 7 { switch info { case 20: return false, nil case 21: return true, nil case 22, 23: return nil, nil } _, err := r.arg(info) // a float: not used, skipped return nil, err } n, err := r.arg(info) if err != nil { return nil, err } switch major { case 0: return int64(n), nil case 1: return -1 - int64(n), nil case 2: return r.take(n) case 3: s, err := r.take(n) return string(s), err case 4: if n > 1024 { return nil, errCBOR } arr := make([]any, 0, n) for k := uint64(0); k < n; k++ { v, err := r.item(depth + 1) if err != nil { return nil, err } arr = append(arr, v) } return arr, nil case 5: if n > 1024 { return nil, errCBOR } m := make(map[any]any, n) for k := uint64(0); k < n; k++ { key, err := r.item(depth + 1) if err != nil { return nil, err } v, err := r.item(depth + 1) if err != nil { return nil, err } switch key.(type) { case int64, string: m[key] = v default: return nil, errCBOR } } return m, nil case 6: return r.item(depth + 1) // a tag: its content } return nil, errCBOR } // ---------- handlers ---------- func credParams() []map[string]any { return []map[string]any{ {"type": "public-key", "alg": -7}, // ES256 {"type": "public-key", "alg": -8}, // EdDSA {"type": "public-key", "alg": -257}, // RS256 } } func userHandle(id int64) string { b := make([]byte, 8) binary.BigEndian.PutUint64(b, uint64(id)) return b64u.EncodeToString(b) } // GET /api/panel/account - who is signed in, and their passkeys. func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) { a := s.currentAdmin(r) keys, _ := s.db.ListAdminKeys(a.ID) list := []map[string]any{} for _, k := range keys { list = append(list, map[string]any{"id": k.ID, "name": k.Name, "created": k.CreatedAt, "last_used": k.LastUsed}) } writeJSON(w, map[string]any{"username": a.Username, "keys": list}) } // POST /api/panel/account/keys/begin - options to make a new passkey with. func (s *Server) handleKeyRegisterBegin(w http.ResponseWriter, r *http.Request) { a := s.currentAdmin(r) keys, _ := s.db.ListAdminKeys(a.ID) exclude := []map[string]any{} for _, k := range keys { exclude = append(exclude, map[string]any{"type": "public-key", "id": b64u.EncodeToString(k.CredID)}) } writeJSON(w, map[string]any{"ok": true, "options": map[string]any{ "challenge": s.challenges.issue(a.ID), "rp": map[string]any{"id": rpID(r), "name": "LPRA"}, "user": map[string]any{"id": userHandle(a.ID), "name": a.Username, "displayName": a.Username}, "pubKeyCredParams": credParams(), "excludeCredentials": exclude, "authenticatorSelection": map[string]any{ "residentKey": "preferred", "userVerification": "preferred", }, "attestation": "none", "timeout": 120000, }}) } // POST /api/panel/account/keys/finish - keep the passkey the browser made. func (s *Server) handleKeyRegisterFinish(w http.ResponseWriter, r *http.Request) { a := s.currentAdmin(r) var in struct { Name string `json:"name"` ClientDataJSON string `json:"clientDataJSON"` AttestationObject string `json:"attestationObject"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil { writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"}) return } fail := func(err error) { writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) } cd, err1 := b64u.DecodeString(in.ClientDataJSON) att, err2 := b64u.DecodeString(in.AttestationObject) if err1 != nil || err2 != nil { fail(errors.New("плохой запрос")) return } if err := checkClientData(r, cd, "webauthn.create", a.ID, &s.challenges); err != nil { fail(err) return } v, _, err := cborDecode(att) m, ok := v.(map[any]any) if err != nil || !ok { fail(errCBOR) return } raw, _ := m["authData"].([]byte) ad, err := parseAuthData(raw, true) if err != nil { fail(err) return } if err := ad.check(r); err != nil { fail(err) return } der, err := coseToPKIX(ad.coseKey) if err != nil { fail(err) return } if k, _ := s.db.AdminKeyByCredID(ad.credID); k != nil { fail(errors.New("этот ключ уже добавлен")) return } name := strings.TrimSpace(in.Name) if name == "" { name = "ключ " + time.Now().Format("02.01.2006") } if len(name) > 60 { name = name[:60] } if err := s.db.AddAdminKey(AdminKey{AdminID: a.ID, CredID: ad.credID, PublicKey: der, SignCount: ad.signCount, Name: name}); err != nil { fail(err) return } s.db.Audit(a.ID, "", "passkey_add:"+name) writeJSON(w, map[string]any{"ok": true}) } // POST /api/panel/account/keys/{id}/delete func (s *Server) handleKeyDelete(w http.ResponseWriter, r *http.Request) { a := s.currentAdmin(r) id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) if err := s.db.DeleteAdminKey(a.ID, id); err != nil { writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) return } s.db.Audit(a.ID, "", "passkey_delete:"+strconv.FormatInt(id, 10)) writeJSON(w, map[string]any{"ok": true}) } // POST /api/login/key/begin - options to sign in with a passkey. With a // username, its keys are named, for keys that do not keep the account // themselves. func (s *Server) handleKeyLoginBegin(w http.ResponseWriter, r *http.Request) { if s.limiter.blocked(clientIP(r)) { writeJSON(w, map[string]any{"ok": false, "error": "Слишком много попыток. Подождите немного."}) return } var in struct { Username string `json:"username"` } _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in) allow := []map[string]any{} if u := strings.TrimSpace(in.Username); u != "" { if a, _ := s.db.AdminByUsername(u); a != nil { keys, _ := s.db.ListAdminKeys(a.ID) for _, k := range keys { allow = append(allow, map[string]any{"type": "public-key", "id": b64u.EncodeToString(k.CredID)}) } } } writeJSON(w, map[string]any{"ok": true, "options": map[string]any{ "challenge": s.challenges.issue(0), "rpId": rpID(r), "allowCredentials": allow, "userVerification": "preferred", "timeout": 120000, }}) } // POST /api/login/key/finish - check the signature and sign the admin in. func (s *Server) handleKeyLoginFinish(w http.ResponseWriter, r *http.Request) { ip := clientIP(r) if s.limiter.blocked(ip) { writeJSON(w, map[string]any{"ok": false, "error": "Слишком много попыток. Подождите немного."}) return } var in struct { ID string `json:"id"` ClientDataJSON string `json:"clientDataJSON"` AuthenticatorData string `json:"authenticatorData"` Signature string `json:"signature"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil { writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"}) return } fail := func(msg string) { s.limiter.fin(ip, false) writeJSON(w, map[string]any{"ok": false, "error": msg}) } credID, e1 := b64u.DecodeString(in.ID) cd, e2 := b64u.DecodeString(in.ClientDataJSON) rawAD, e3 := b64u.DecodeString(in.AuthenticatorData) sig, e4 := b64u.DecodeString(in.Signature) if e1 != nil || e2 != nil || e3 != nil || e4 != nil { fail("плохой запрос") return } k, _ := s.db.AdminKeyByCredID(credID) if k == nil { fail("этот ключ здесь не привязан ни к одному аккаунту") return } if err := checkClientData(r, cd, "webauthn.get", 0, &s.challenges); err != nil { fail(err.Error()) return } ad, err := parseAuthData(rawAD, false) if err == nil { err = ad.check(r) } if err != nil { fail(err.Error()) return } cdHash := sha256.Sum256(cd) if !verifySig(k.PublicKey, append(append([]byte{}, rawAD...), cdHash[:]...), sig) { fail("подпись ключа не сошлась") return } // A counter that goes back means a copy of the key is in use. if ad.signCount != 0 && k.SignCount != 0 && ad.signCount <= k.SignCount { fail("ключ похож на копию: счётчик не вырос") return } _ = s.db.UseAdminKey(k.ID, ad.signCount) s.limiter.fin(ip, true) if err := s.setSession(w, k.AdminID); err != nil { writeJSON(w, map[string]any{"ok": false, "error": "внутренняя ошибка"}) return } s.db.Audit(k.AdminID, "", "login_passkey:"+k.Name) writeJSON(w, map[string]any{"ok": true}) } // POST /settings/admins/keys/reset - drop all passkeys of an admin, who signs // in with the password again: for one who lost theirs. func (s *Server) handleResetAdminKeys(w http.ResponseWriter, r *http.Request) { if !checkCSRF(r) { http.Error(w, "bad csrf", http.StatusForbidden) return } id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64) _ = s.db.DeleteAdminKeys(id) if a := s.currentAdmin(r); a != nil { s.db.Audit(a.ID, "", fmt.Sprintf("passkeys_reset:%d", id)) } http.Redirect(w, r, "/settings", http.StatusSeeOther) }