jarld obtains and renews a certificate for another host; the host asks
for it with one command, waits, downloads it when it is newer than its
copy and runs a hook with the paths in JARL_CERT_* variables.
- Certificates have an owner user; another user can't take the name.
An existing one is obtained again only when a name is added or
removed. A new certificates grant (names=) says which names a user
may have; it is never part of "*". Users may own
certificates.max_per_user certificates (10) or their own cert_limit.
- POST /certificates/{name}/request; owners may list, show, export and
delete theirs; keys only over HTTPS, the socket or loopback; every
download is recorded and shown as "Pulled by".
- CLI: jarl cert get (saved settings, all saved when no name), cert
saved, cert forget, cert schedule (daily systemd timer, Linux, root).
- Migration 14. Tests: server, auth, and e2e against Pebble.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
157 lines
5.6 KiB
Go
157 lines
5.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"jarl/internal/model"
|
|
)
|
|
|
|
func TestPasswords(t *testing.T) {
|
|
h, err := HashPassword("correct horse battery")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(h, "$argon2id$v=19$m=19456,t=2,p=1$") {
|
|
t.Errorf("hash format %q", h)
|
|
}
|
|
if !CheckPassword(h, "correct horse battery") || CheckPassword(h, "wrong horse battery") {
|
|
t.Error("CheckPassword wrong")
|
|
}
|
|
if CheckPassword("", "anything") || CheckPassword("garbage", "x") {
|
|
t.Error("bad hash accepted")
|
|
}
|
|
if _, err := HashPassword("short"); err == nil {
|
|
t.Error("short password accepted")
|
|
}
|
|
}
|
|
|
|
func TestTokens(t *testing.T) {
|
|
tok, pid, hash := NewToken()
|
|
if !strings.HasPrefix(tok, "jarl_") || len(tok) != len("jarl_")+16+1+32 {
|
|
t.Fatalf("token %q", tok)
|
|
}
|
|
p, secret, err := ParseToken(tok)
|
|
if err != nil || p != pid || !CheckSecret(secret, hash) {
|
|
t.Fatalf("ParseToken: %v %v", p, err)
|
|
}
|
|
if CheckSecret(secret+"x", hash) {
|
|
t.Error("wrong secret accepted")
|
|
}
|
|
for _, bad := range []string{"", "abc", "jarl_short_x", "Bearer " + tok} {
|
|
if _, _, err := ParseToken(bad); err == nil {
|
|
t.Errorf("ParseToken(%q) accepted", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseGrant(t *testing.T) {
|
|
g, err := ParseGrant("records:write zones=Example.COM. names=_acme-challenge.* types=txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if g.Zones[0] != "example.com" || g.Types[0] != "TXT" || g.Names[0] != "_acme-challenge.*" {
|
|
t.Errorf("grant = %+v", g)
|
|
}
|
|
if FormatGrant(g) != "records:write zones=example.com names=_acme-challenge.* types=TXT" {
|
|
t.Errorf("FormatGrant = %q", FormatGrant(g))
|
|
}
|
|
for _, bad := range []string{"", "records", "records:admin", "bogus:read", "acls:write names=x", "records:read color=red", "options:read zones=x"} {
|
|
if _, err := ParseGrant(bad); err == nil {
|
|
t.Errorf("ParseGrant(%q) accepted", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPermissions(t *testing.T) {
|
|
acme, _ := ParseGrant("records:write zones=example.com names=_acme-challenge.* types=TXT")
|
|
reader, _ := ParseGrant("*:read")
|
|
u := &model.User{Username: "bot", Grants: []model.Grant{acme, reader}}
|
|
p := &Principal{User: u}
|
|
allow := []Action{
|
|
{Resource: ResRecords, Write: true, Zone: "example.com.", Name: "_acme-challenge.www.example.com.", Type: "TXT"},
|
|
{Resource: ResRecords, Write: true, Zone: "example.com.", Name: "_acme-challenge.example.com.", Type: "txt"},
|
|
{Resource: ResRecords, Zone: "other.org.", Name: "www.other.org.", Type: "A"},
|
|
{Resource: ResACLs},
|
|
}
|
|
deny := []Action{
|
|
{Resource: ResRecords, Write: true, Zone: "example.com.", Name: "www.example.com.", Type: "TXT"},
|
|
{Resource: ResRecords, Write: true, Zone: "example.com.", Name: "_acme-challenge.example.com.", Type: "A"},
|
|
{Resource: ResRecords, Write: true, Zone: "other.org.", Name: "_acme-challenge.other.org.", Type: "TXT"},
|
|
{Resource: ResZones, Write: true, Zone: "example.com."},
|
|
{Resource: ResACLs, Write: true},
|
|
}
|
|
for _, a := range allow {
|
|
if !p.Can(a) {
|
|
t.Errorf("denied %s", a)
|
|
}
|
|
}
|
|
for _, a := range deny {
|
|
if p.Can(a) {
|
|
t.Errorf("allowed %s", a)
|
|
}
|
|
}
|
|
// A token narrows its user's permissions; it can't widen them.
|
|
tp := &Principal{User: u, Token: &model.Token{Grants: []model.Grant{acme}}}
|
|
if tp.Can(Action{Resource: ResACLs}) {
|
|
t.Error("token restriction ignored")
|
|
}
|
|
if !tp.Can(allow[0]) {
|
|
t.Error("token denied allowed action")
|
|
}
|
|
wide := &Principal{User: u, Token: &model.Token{Grants: []model.Grant{{Resource: ResAll, Access: Write}}}}
|
|
if wide.Can(Action{Resource: ResACLs, Write: true}) {
|
|
t.Error("token widened user's permissions")
|
|
}
|
|
// Admins can do everything, but not through restricted tokens.
|
|
admin := &model.User{Username: "root", Admin: true}
|
|
if !(&Principal{User: admin}).IsAdmin() || (&Principal{User: admin, Token: &model.Token{Grants: []model.Grant{reader}}}).IsAdmin() {
|
|
t.Error("admin via restricted token")
|
|
}
|
|
if (&Principal{User: &model.User{Admin: true, Disabled: true}}).Can(Action{Resource: ResACLs}) {
|
|
t.Error("disabled user allowed")
|
|
}
|
|
// View filters, with "_default" for zones outside views.
|
|
vg, _ := ParseGrant("zones:write views=external")
|
|
vp := &Principal{User: &model.User{Grants: []model.Grant{vg}}}
|
|
if !vp.Can(Action{Resource: ResZones, Write: true, Zone: "example.com.", View: "external"}) || vp.Can(Action{Resource: ResZones, Write: true, Zone: "example.com.", View: "internal"}) || vp.Can(Action{Resource: ResZones, Write: true, Zone: "example.com."}) {
|
|
t.Error("view filter wrong")
|
|
}
|
|
// CanSeeZone with a filtered records grant.
|
|
sp := &Principal{User: &model.User{Grants: []model.Grant{acme}}}
|
|
if !sp.CanSeeZone("example.com.", "") || sp.CanSeeZone("other.org.", "") {
|
|
t.Error("CanSeeZone wrong")
|
|
}
|
|
}
|
|
|
|
func TestRoleTemplatesValid(t *testing.T) {
|
|
for name, gs := range RoleTemplates {
|
|
for _, g := range gs {
|
|
gg := g
|
|
if err := ValidateGrant(&gg); err != nil {
|
|
t.Errorf("template %s: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCertificateGrants(t *testing.T) {
|
|
g, err := ParseGrant("certificates:write names=*.adbraun.lol")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !GrantAllows(g, Action{Resource: ResCerts, Write: true, Name: "mail.adbraun.lol"}) || GrantAllows(g, Action{Resource: ResCerts, Write: true, Name: "mail.example.org"}) {
|
|
t.Error("names filter")
|
|
}
|
|
if _, err := ParseGrant("certificates:read types=TXT"); err == nil {
|
|
t.Error("types on certificates accepted")
|
|
}
|
|
if _, err := ParseGrant("certificates:read zones=adbraun.lol"); err == nil {
|
|
t.Error("zones on certificates accepted")
|
|
}
|
|
// "*" never covers certificates: they hand out private keys.
|
|
if GrantAllows(model.Grant{Resource: ResAll, Access: Write}, Action{Resource: ResCerts, Name: "mail.adbraun.lol"}) {
|
|
t.Error("* covers certificates")
|
|
}
|
|
}
|