Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
485d030f7a | ||
|
|
0cc7b9d3da |
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.4.0
|
||||
2.4.4
|
||||
|
||||
Reference in New Issue
Block a user