Add KeepOrg sync server: accounts, nested groups, versioned KDBX sync
Why: collaborative KeePass use needs one arbiter of "who wrote last" that never sees a master password, plus access control that follows an org chart. What: stdlib-only Go server. Metadata is a single JSON document with one writer and atomic commit; KDBX payloads are versioned blobs on disk. Access comes from a group tree where a grant on a group reaches every member below it. Login is password+TOTP, invite links, or OIDC through a browser-approved device flow. Pushes carry If-Match and are arbitrated by a per-database merge strategy (server-wins by default). Updates are pushed to clients over SSE. Proved: go test ./internal/... covers RFC 6238 vectors, ACL inheritance and isolation, store rollback, blob pruning, every push verdict, and an end-to-end HTTP pass over login, conflict, force, read-only grants, invite redemption, TOTP enrolment, device login and the event stream.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
// Package totp implements RFC 6238 time-based one-time passwords, the
|
||||
// built-in second factor for password logins.
|
||||
package totp
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
digits = 6
|
||||
step = 30 * time.Second
|
||||
// skew is how many steps either side of now are accepted, covering
|
||||
// clock drift and a user typing slowly.
|
||||
skew = 1
|
||||
)
|
||||
|
||||
// NewSecret returns a fresh base32 secret with no padding, as authenticator
|
||||
// apps expect.
|
||||
func NewSecret() string {
|
||||
b := make([]byte, 20)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("totp: " + err.Error())
|
||||
}
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
|
||||
}
|
||||
|
||||
// URI is the otpauth:// URL an authenticator app can scan.
|
||||
func URI(issuer, account, secret string) string {
|
||||
label := url.PathEscape(issuer + ":" + account)
|
||||
q := url.Values{"secret": {secret}, "issuer": {issuer}, "algorithm": {"SHA1"},
|
||||
"digits": {fmt.Sprint(digits)}, "period": {"30"}}
|
||||
return "otpauth://totp/" + label + "?" + q.Encode()
|
||||
}
|
||||
|
||||
// Code is the password for secret at time t.
|
||||
func Code(secret string, t time.Time) (string, error) {
|
||||
key, err := decode(secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return code(key, uint64(t.Unix())/uint64(step.Seconds())), nil
|
||||
}
|
||||
|
||||
// Check reports whether given is a valid password for secret at time t,
|
||||
// allowing one step of drift either way.
|
||||
func Check(secret, given string, t time.Time) bool {
|
||||
key, err := decode(secret)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
given = strings.TrimSpace(given)
|
||||
if len(given) != digits {
|
||||
return false
|
||||
}
|
||||
ctr := uint64(t.Unix()) / uint64(step.Seconds())
|
||||
for d := -skew; d <= skew; d++ {
|
||||
if hmac.Equal([]byte(code(key, ctr+uint64(d))), []byte(given)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func decode(secret string) ([]byte, error) {
|
||||
s := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(secret), " ", ""))
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(s)
|
||||
}
|
||||
|
||||
func code(key []byte, counter uint64) string {
|
||||
var buf [8]byte
|
||||
binary.BigEndian.PutUint64(buf[:], counter)
|
||||
mac := hmac.New(sha1.New, key)
|
||||
mac.Write(buf[:])
|
||||
sum := mac.Sum(nil)
|
||||
off := sum[len(sum)-1] & 0x0f
|
||||
trunc := binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff
|
||||
mod := uint32(1)
|
||||
for i := 0; i < digits; i++ {
|
||||
mod *= 10
|
||||
}
|
||||
return fmt.Sprintf("%0*d", digits, trunc%mod)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package totp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RFC 6238 appendix B, SHA1 test vectors for the ASCII key "12345678901234567890".
|
||||
func TestRFC6238Vectors(t *testing.T) {
|
||||
const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" // base32 of the RFC key
|
||||
for _, tc := range []struct {
|
||||
unix int64
|
||||
want string
|
||||
}{
|
||||
{59, "287082"},
|
||||
{1111111109, "081804"},
|
||||
{1111111111, "050471"},
|
||||
{1234567890, "005924"},
|
||||
{2000000000, "279037"},
|
||||
} {
|
||||
got, err := Code(secret, time.Unix(tc.unix, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("Code at %d = %s, want %s", tc.unix, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAcceptsDriftAndRejectsJunk(t *testing.T) {
|
||||
s := NewSecret()
|
||||
now := time.Now()
|
||||
c, _ := Code(s, now)
|
||||
if !Check(s, c, now.Add(29*time.Second)) {
|
||||
t.Fatal("code within drift window rejected")
|
||||
}
|
||||
if Check(s, c, now.Add(5*time.Minute)) {
|
||||
t.Fatal("stale code accepted")
|
||||
}
|
||||
for _, bad := range []string{"", "12345", "abcdef", "1234567"} {
|
||||
if Check(s, bad, now) {
|
||||
t.Fatalf("junk code %q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user