3 Commits
Author SHA1 Message Date
Michael Wesemann 485d030f7a [mike@mwxm4] 2026-08-12 17:14:11 +02:00
Michael Wesemann 0cc7b9d3da [mike@mwxm4] 2026-08-12 14:56:04 +02:00
Michael Wesemann 4488787971 [mike@mwxm4] 2026-08-12 12:19:22 +02:00
4 changed files with 281 additions and 30 deletions
+103 -23
View File
@@ -11,19 +11,30 @@
// nothing. A run without a terminal — the certbot hooks, cron — never asks: it
// says what is missing and stops.
//
// ~/.dnsrc is encrypted too, under FILEKEY, which the program carries and
// nobody is asked for. It is the same AES-256-GCM, and the file opens on every
// machine dns runs on, so cron and the hooks notice nothing. What it buys is
// that the password no longer stands in the clear in a backup, in a synced home
// directory or on a screen someone else is looking at. A file from before this,
// plain JSON, is still read, and written back encrypted on the next run.
//
// Rotating the infoblox password means 'dns --seal', pasting the line it
// prints into this file, rebuilding, and removing the stale ~/.dnsrc wherever
// one exists.
//
// What this is not: whoever knows the passphrase has the login, and so has
// whoever can read ~/.dnsrc. It keeps the credentials out of the repository and
// out of the binary. It is not a vault.
// whoever holds ~/.dnsrc together with a copy of dns — FILEKEY is in every one
// of them, and prising it out is an afternoon's work, not a cluster's. That is
// why the file stays 0600. It keeps the credentials out of the repository and
// out of plain sight on disk. It is not a vault.
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
@@ -37,8 +48,23 @@ import (
var SEALED = "B/RvQRI1EfziN3EoEY0obzrVeQsIMeN1QzBiR4Pl8PaygMUqlK1vggmbObjceeF+tmW3npiuXAvp93R18u9bejlv5M/3qL5Ix3fOpi+L5p3x90oXni7fhlPtc9Z3"
// The key ~/.dnsrc is written under. Thirty-two random bytes, so there is
// nothing to guess and no reason to slow a guesser down: hkdf, not argon2, and
// every run opens the file in microseconds instead of a third of a second.
//
// A build may put another one in its place with -ldflags "-X main.FILEKEY=...".
// Files the earlier builds wrote then no longer open, and dns says so and asks
// for the passphrase again — which a cron run cannot do, so a key changed under
// a running installation is changed for the hooks as well.
var FILEKEY = "mphYib5GBHwMnKE0F3of3V8+rpS4ayUlXvaMncaZ3wE="
const CREDSFILE = ".dnsrc"
// The first bytes of an encrypted ~/.dnsrc. It tells the file apart from the
// plain JSON of older versions, and leaves room to tell it apart from whatever
// a later version writes should FILEKEY ever have to change.
const FILETAG = "dnsrc1:"
// argon2id, the second of the two settings RFC 9106 recommends: 64 MB and three
// passes. It costs a fraction of a second here and makes an offline run through
// a list of likely passphrases expensive on hardware built for it.
@@ -62,7 +88,18 @@ type credentials struct {
func getcreds() (string, string) { // ----------------------------------- the login, from ~/.dnsrc or the passphrase
path:=credspath()
if c,err:=readcreds(path); err==nil { return c.User,c.Pass }
c,plain,err:=readcreds(path)
if err==nil {
if (plain) { // written before ~/.dnsrc was encrypted: put it away properly
if err:=writecreds(path,c); err!=nil { PE("cannot encrypt "+path,err.Error()) } else { PO(path+" is now encrypted") }
}
return c.User,c.Pass
}
// A file that is there but will not open — meddled with, truncated, written
// by a build with a different FILEKEY — is worth saying out loud before the
// passphrase is asked for and the file written afresh.
if (!os.IsNotExist(err)) { PE("cannot read "+path,err.Error()) }
if (SEALED=="") {
PE("this build carries no credentials","run 'dns --seal' and paste the line into creds.go")
@@ -74,7 +111,7 @@ func getcreds() (string, string) { // ----------------------------------- the lo
os.Exit(1)
}
c:=askpassphrase()
c=askpassphrase()
if err:=writecreds(path,c); err!=nil {
PE("cannot write "+path,err.Error()) // the login still works for this one run
@@ -122,14 +159,37 @@ func sealcmd() { // --------------------------------------------- 'dns --seal':
// ===================================================================================================== THE BLOCK
func seal(c credentials, pass string) (string, error) { // ------------------------------------- encrypt the login
// The block in creds.go and the file in the home directory are the same thing
// twice, encrypted the same way and differing only in which key opens them:
// lock and unlock do the work, and what is handed in decides whether that is
// the shared passphrase or FILEKEY.
func seal(c credentials, pass string) (string, error) { // ------------------- encrypt the login for creds.go
return lock(c,func(salt []byte) (cipher.AEAD,error) { return credsgcm(pass,salt) })
}
func unseal(blob string, pass string) (credentials, error) { // ------------- decrypt the login from creds.go
return unlock(blob,"the sealed block",func(salt []byte) (cipher.AEAD,error) { return credsgcm(pass,salt) })
}
func lockcreds(c credentials) (string, error) { // -------------------------- encrypt the login for ~/.dnsrc
blob,err:=lock(c,filegcm)
if err!=nil { return "",err }
return FILETAG+blob,nil
}
func opencreds(blob string) (credentials, error) { // ---------------------- decrypt the login from ~/.dnsrc
return unlock(strings.TrimPrefix(blob,FILETAG),"the credentials",filegcm)
}
func lock(c credentials, keyed func([]byte) (cipher.AEAD, error)) (string, error) { // -------- encrypt the login
plain,err:=json.Marshal(c)
if err!=nil { return "",err }
salt:=make([]byte,SALTLEN)
if _,err:=rand.Read(salt); err!=nil { return "",err }
gcm,err:=credsgcm(pass,salt)
gcm,err:=keyed(salt)
if err!=nil { return "",err }
nonce:=make([]byte,gcm.NonceSize())
@@ -145,27 +205,28 @@ func seal(c credentials, pass string) (string, error) { // ---------------------
return base64.StdEncoding.EncodeToString(out),nil
}
func unseal(blob string, pass string) (credentials, error) { // -------------------------------- decrypt the login
func unlock(blob string, what string, keyed func([]byte) (cipher.AEAD, error)) (credentials, error) { // - decrypt
var c credentials
raw,err:=base64.StdEncoding.DecodeString(strings.TrimSpace(blob))
if err!=nil { return c,errors.New("the sealed block is not valid base64") }
if err!=nil { return c,errors.New(what+" is not valid base64") }
gcm,err:=credsgcm(pass,raw[:min(SALTLEN,len(raw))])
gcm,err:=keyed(raw[:min(SALTLEN,len(raw))])
if err!=nil { return c,err }
if (len(raw) < SALTLEN+gcm.NonceSize()+gcm.Overhead()) {
return c,errors.New("the sealed block is too short")
return c,errors.New(what+" is too short")
}
nonce:=raw[SALTLEN : SALTLEN+gcm.NonceSize()]
// A wrong passphrase derives a wrong key, and the tag does not check out —
// the same error a block someone has meddled with produces.
// A wrong key — a mistyped passphrase, a FILEKEY that has moved on — and the
// tag does not check out: the same error a block someone has meddled with
// produces.
plain,err:=gcm.Open(nil,nonce,raw[SALTLEN+gcm.NonceSize():],nil)
if err!=nil { return c,errors.New("cannot open the sealed block") }
if err!=nil { return c,errors.New("cannot open "+what) }
if err:=json.Unmarshal(plain,&c); err!=nil { return c,err }
if (c.User=="" || c.Pass=="") { return c,errors.New("the sealed block holds no login") }
if (c.User=="" || c.Pass=="") { return c,errors.New(what+" holds no login") }
return c,nil
}
@@ -176,6 +237,14 @@ func credsgcm(pass string, salt []byte) (cipher.AEAD, error) { // --------------
return cipher.NewGCM(block)
}
func filegcm(salt []byte) (cipher.AEAD, error) { // -------------------------------------- FILEKEY and salt to a key
key,err:=hkdf.Key(sha256.New,[]byte(FILEKEY),salt,CREDSFILE,KEYLEN)
if err!=nil { return nil,err }
block,err:=aes.NewCipher(key)
if err!=nil { return nil,err }
return cipher.NewGCM(block)
}
// ====================================================================================================== ~/.DNSRC
@@ -186,25 +255,36 @@ func credspath() string { // ---------------------------------------------------
return filepath.Join(home,CREDSFILE)
}
func readcreds(path string) (credentials, error) { // ------------------------------------------------- read it
// The second return says the file was still the plain JSON of an older dns.
// The login in it is good, and getcreds writes it back encrypted; refusing it
// would strand a cron run on a file it could perfectly well use.
func readcreds(path string) (credentials, bool, error) { // ------------------------------------ read it, either form
var c credentials
st,err:=os.Stat(path)
if err!=nil { return c,err }
if err!=nil { return c,false,err }
if (st.Mode().Perm()&0o077 != 0) { PE(path+" can be read by others",SF("chmod 600 %s",path)) }
b,err:=os.ReadFile(path)
if err!=nil { return c,err }
if err!=nil { return c,false,err }
txt:=strings.TrimSpace(string(b))
if err:=json.Unmarshal(b,&c); err!=nil { return c,err }
if (c.User=="" || c.Pass=="") { return c,errors.New("no login in "+path) }
return c,nil
if (strings.HasPrefix(txt,FILETAG)) {
c,err=opencreds(txt)
return c,false,err
}
if (!strings.HasPrefix(txt,"{")) { return c,false,errors.New(path+" is not a credentials file") }
if err:=json.Unmarshal([]byte(txt),&c); err!=nil { return c,true,err }
if (c.User=="" || c.Pass=="") { return c,true,errors.New("no login in "+path) }
return c,true,nil
}
func writecreds(path string, c credentials) error { // ----------------------------------------------- write it
b,err:=json.MarshalIndent(c,""," ")
func writecreds(path string, c credentials) error { // ------------------------------------------ write it, encrypted
blob,err:=lockcreds(c)
if err!=nil { return err }
b=append(b,'\n')
b:=[]byte(blob+"\n")
// Alongside first, then renamed: nobody comes upon half a file, and the login
// is never on disk readable by others, not even for a moment — CreateTemp
+155 -5
View File
@@ -2,8 +2,10 @@ package main
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -83,24 +85,172 @@ func TestCredsFileIsPrivate(t *testing.T) {
t.Errorf("mode is %04o, want 0600", perm)
}
got, err := readcreds(path)
got, plain, err := readcreds(path)
if err != nil {
t.Fatalf("readcreds: %v", err)
}
if got != testCreds {
t.Errorf("got %+v, want %+v", got, testCreds)
}
if plain {
t.Error("a file dns just wrote was taken for an old plaintext one")
}
// Nothing written, nothing to read: the first run has to fall through to the
// passphrase rather than come back with an empty login.
if _, err := readcreds(filepath.Join(t.TempDir(), ".dnsrc")); err == nil {
if _, _, err := readcreds(filepath.Join(t.TempDir(), ".dnsrc")); err == nil {
t.Error("a missing file was accepted")
}
if err := os.WriteFile(path, []byte(`{"user":"","password":""}`), 0o600); err != nil {
}
// What lands on disk must not read out the login, and must not be the plain
// JSON of before — that is the whole point of the exercise.
func TestCredsFileIsEncrypted(t *testing.T) {
path := filepath.Join(t.TempDir(), ".dnsrc")
if err := writecreds(path, testCreds); err != nil {
t.Fatalf("writecreds: %v", err)
}
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if !strings.HasPrefix(string(b), FILETAG) {
t.Errorf("the file does not begin with %q", FILETAG)
}
for _, s := range []string{testCreds.User, testCreds.Pass, `"password"`} {
if bytesContains(b, []byte(s)) {
t.Errorf("%q stands in the clear in the file", s)
}
}
// Two writes of the same login differ: salt and nonce are fresh each time.
first := string(b)
if err := writecreds(path, testCreds); err != nil {
t.Fatalf("writecreds: %v", err)
}
if b, _ = os.ReadFile(path); string(b) == first {
t.Error("two writes of the same login are identical")
}
}
func TestCredsFileRejects(t *testing.T) {
dir := t.TempDir()
// A byte turned over in the ciphertext, a file that is not one of ours, and
// an encrypted file holding nothing: none of them may pass as a login.
blob, err := lockcreds(testCreds)
if err != nil {
t.Fatalf("lockcreds: %v", err)
}
raw, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(blob, FILETAG))
raw[len(raw)-1] ^= 0x01
for name, body := range map[string]string{
"tampered": FILETAG + base64.StdEncoding.EncodeToString(raw),
"foreign": "just some text someone put here",
"empty": "",
"nologin": `{"user":"","password":""}`,
} {
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(body+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := readcreds(path); err == nil {
t.Errorf("a %s file was accepted", name)
}
}
}
// The file an older dns wrote is still read, and flagged so getcreds writes it
// back encrypted. Anything else would stop the certbot hooks on a home
// directory that has not seen an interactive run yet.
func TestCredsFileFromBefore(t *testing.T) {
path := filepath.Join(t.TempDir(), ".dnsrc")
b, err := json.Marshal(testCreds)
if err != nil {
t.Fatal(err)
}
if _, err := readcreds(path); err == nil {
t.Error("a file without a login was accepted")
if err := os.WriteFile(path, append(b, '\n'), 0o600); err != nil {
t.Fatal(err)
}
got, plain, err := readcreds(path)
if err != nil {
t.Fatalf("readcreds: %v", err)
}
if got != testCreds {
t.Errorf("got %+v, want %+v", got, testCreds)
}
if !plain {
t.Error("a plaintext file was not reported as one")
}
// And once written back it opens as an encrypted one, with the same login.
if err := writecreds(path, got); err != nil {
t.Fatalf("writecreds: %v", err)
}
got, plain, err = readcreds(path)
if err != nil || got != testCreds || plain {
t.Errorf("after rewriting: %+v, plain %v, err %v", got, plain, err)
}
}
// The whole way through, on a home directory holding a file from an older dns:
// the login comes back, the file is encrypted afterwards, and the next run —
// the one from cron, which can ask nobody anything — reads it again.
func TestGetcredsEncryptsWhatItFinds(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
path := filepath.Join(dir, CREDSFILE)
b, err := json.Marshal(testCreds)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, append(b, '\n'), 0o600); err != nil {
t.Fatal(err)
}
if user, pass := getcreds(); user != testCreds.User || pass != testCreds.Pass {
t.Fatalf("got %q/%q, want %q/%q", user, pass, testCreds.User, testCreds.Pass)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(string(raw), FILETAG) {
t.Fatal("the file was not written back encrypted")
}
if user, pass := getcreds(); user != testCreds.User || pass != testCreds.Pass {
t.Errorf("second run: got %q/%q, want %q/%q", user, pass, testCreds.User, testCreds.Pass)
}
}
func TestLockcredsRoundtrip(t *testing.T) {
blob, err := lockcreds(testCreds)
if err != nil {
t.Fatalf("lockcreds: %v", err)
}
got, err := opencreds(blob)
if err != nil {
t.Fatalf("opencreds: %v", err)
}
if got != testCreds {
t.Errorf("got %+v, want %+v", got, testCreds)
}
// A different FILEKEY does not open it — the file is worth something only
// together with the binary that wrote it.
old := FILEKEY
FILEKEY = "c29tZXRoaW5nIGVsc2UgZW50aXJlbHksIHRoaXJ0eSB0d28="
_, err = opencreds(blob)
FILEKEY = old
if err == nil {
t.Error("another FILEKEY opened the file")
}
}
+22 -1
View File
@@ -6,6 +6,11 @@
// that block it brings no names that do not begin with "selfUpdate" or
// "update".
//
// One thing to know before copying it on: this copy does not verify the TLS
// certificate of the gitea it talks to — see updateClient at the foot of the
// file, where it says what that costs. That is a decision taken for this estate
// and not a property of the file; anywhere else, take it out again.
//
// It assumes the layout build.sh produces: one release per version, whose tag
// is the bare number (2.1.6, a leading "v" is allowed), holding one asset
// "<name>-<goos>-<goarch>" each — that is, exactly the files from ./bin. Under
@@ -16,6 +21,7 @@ package main
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
@@ -394,7 +400,22 @@ func (u selfUpdater) apiBase() (string, error) {
// One time limit for all of it: the look costs a few hundred milliseconds, the
// download a few megabytes — both may hang, but not forever.
var updateClient = &http.Client{Timeout: 5 * time.Minute}
//
// The certificate is deliberately not verified. Machines in this estate that
// carry no current trust store cannot build a path to the chain the gitea
// serves, and fixing that on each of them was not the road taken. Be clear
// about the price: anyone able to place themselves in the network path between
// this program and the gitea can hand it any binary at all, and install() will
// put that binary in place of the running one and it will be run. The probe
// afterwards is no guard against this — a hostile binary prints whatever
// version string is asked of it; the probe catches a truncated download, not a
// substituted one. What is left protecting the update is the network itself.
var updateClient = &http.Client{
Timeout: 5 * time.Minute,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
func updateGet(ctx context.Context, target string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
+1 -1
View File
@@ -1 +1 @@
2.4.0
2.4.4