Files
lpra/internal/server/passkeys.go
T

566 lines
16 KiB
Go

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