Files
zt/ziti/cmd/lets_encrypt/accounts_storage.go
2025-10-17 12:54:25 -04:00

261 lines
7.1 KiB
Go

/*
Copyright NetFoundry Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package lets_encrypt
import (
"crypto"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/michaelquigley/pfxlog"
"github.com/openziti/ziti/ziti/internal/log"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration"
)
const (
baseAccountsRootFolderName = "accounts"
baseKeysFolderName = "keys"
accountFileName = "account.json"
)
// AccountsStorage A storage for account data.
//
// rootPath:
//
// ./.path/accounts/
// │ └── root accounts directory
// └── "path" option
//
// rootUserPath:
//
// ./.path/accounts/localhost_14000/[email protected]/
// │ │ │ └── userID ("email" option)
// │ │ └── CA server ("server" option)
// │ └── root accounts directory
// └── "path" option
//
// keysPath:
//
// ./.path/accounts/localhost_14000/[email protected]/keys/
// │ │ │ │ └── root keys directory
// │ │ │ └── userID ("email" option)
// │ │ └── CA server ("server" option)
// │ └── root accounts directory
// └── "path" option
//
// accountFilePath:
//
// ./.path/accounts/localhost_14000/[email protected]/account.json
// │ │ │ │ └── account file
// │ │ │ └── userID ("email" option)
// │ │ └── CA server ("server" option)
// │ └── root accounts directory
// └── "path" option
type AccountsStorage struct {
userID string
rootPath string
rootUserPath string
keysPath string
accountFilePath string
}
// NewAccountsStorage Creates a new AccountsStorage.
func NewAccountsStorage(options *leOptions) *AccountsStorage {
serverURL, err := url.Parse(options.acmeserver)
if err != nil {
log.Fatalf("%v", err)
}
rootPath := filepath.Join(options.path, baseAccountsRootFolderName)
serverPath := strings.NewReplacer(":", "_", "/", string(os.PathSeparator)).Replace(serverURL.Host)
accountsPath := filepath.Join(rootPath, serverPath)
rootUserPath := filepath.Join(accountsPath, options.email)
return &AccountsStorage{
userID: options.email,
rootPath: rootPath,
rootUserPath: rootUserPath,
keysPath: filepath.Join(rootUserPath, baseKeysFolderName),
accountFilePath: filepath.Join(rootUserPath, accountFileName),
}
}
func (s *AccountsStorage) ExistsAccountFilePath() bool {
accountFile := filepath.Join(s.rootUserPath, accountFileName)
if _, err := os.Stat(accountFile); os.IsNotExist(err) {
return false
} else if err != nil {
log.Fatalf("%v", err)
}
return true
}
func (s *AccountsStorage) GetRootPath() string {
return s.rootPath
}
func (s *AccountsStorage) GetRootUserPath() string {
return s.rootUserPath
}
func (s *AccountsStorage) GetUserID() string {
return s.userID
}
func (s *AccountsStorage) Save(account *Account) error {
jsonBytes, err := json.MarshalIndent(account, "", "\t")
if err != nil {
return err
}
return os.WriteFile(s.accountFilePath, jsonBytes, filePerm)
}
func (s *AccountsStorage) LoadAccount(options *leOptions, privateKey crypto.PrivateKey) *Account {
fileBytes, err := os.ReadFile(s.accountFilePath)
if err != nil {
log.Fatalf("Could not load file for account %s: %v", s.userID, err)
}
var account Account
err = json.Unmarshal(fileBytes, &account)
if err != nil {
log.Fatalf("Could not parse file for account %s: %v", s.userID, err)
}
account.key = privateKey
if account.Registration == nil || account.Registration.Body.Status == "" {
reg, err := tryRecoverRegistration(options, privateKey)
if err != nil {
log.Fatalf("Could not load account for %s. Registration is nil: %#v", s.userID, err)
}
account.Registration = reg
err = s.Save(&account)
if err != nil {
log.Fatalf("Could not save account for %s. Registration is nil: %#v", s.userID, err)
}
}
return &account
}
func (s *AccountsStorage) GetPrivateKey(keyType certcrypto.KeyType) crypto.PrivateKey {
accKeyPath := filepath.Join(s.keysPath, s.userID+".key")
if _, err := os.Stat(accKeyPath); os.IsNotExist(err) {
log.Infof("No key found for account %s. Generating a %s key.", s.userID, keyType)
s.createKeysFolder()
privateKey, err := generatePrivateKey(accKeyPath, keyType)
if err != nil {
log.Fatalf("Could not generate RSA private account key for account %s: %v", s.userID, err)
}
log.Infof("Saved key to %s", accKeyPath)
return privateKey
}
privateKey, err := loadPrivateKey(accKeyPath)
if err != nil {
log.Fatalf("Could not load private key from file %s: %v", accKeyPath, err)
}
return privateKey
}
func (s *AccountsStorage) createKeysFolder() {
if err := createNonExistingFolder(s.keysPath); err != nil {
log.Fatalf("Could not check/create directory for account %s: %v", s.userID, err)
}
}
func generatePrivateKey(file string, keyType certcrypto.KeyType) (crypto.PrivateKey, error) {
privateKey, err := certcrypto.GeneratePrivateKey(keyType)
if err != nil {
return nil, err
}
certOut, err := os.Create(file)
if err != nil {
return nil, err
}
defer func() {
if closeErr := certOut.Close(); closeErr != nil {
pfxlog.Logger().WithError(closeErr).Error("Could not close file for private key")
}
}()
pemKey := certcrypto.PEMBlock(privateKey)
err = pem.Encode(certOut, pemKey)
if err != nil {
return nil, err
}
return privateKey, nil
}
func loadPrivateKey(file string) (crypto.PrivateKey, error) {
keyBytes, err := os.ReadFile(file)
if err != nil {
return nil, err
}
keyBlock, _ := pem.Decode(keyBytes)
switch keyBlock.Type {
case "RSA PRIVATE KEY":
return x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
case "EC PRIVATE KEY":
return x509.ParseECPrivateKey(keyBlock.Bytes)
case "PRIVATE KEY":
return x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
}
return nil, errors.New("unknown private key type")
}
func tryRecoverRegistration(options *leOptions, privateKey crypto.PrivateKey) (*registration.Resource, error) {
// couldn't load account but got a key. Try to look the account up.
config := lego.NewConfig(&Account{key: privateKey})
config.CADirURL = options.acmeserver
config.UserAgent = "ziti-cli"
client, err := lego.NewClient(config)
if err != nil {
return nil, err
}
reg, err := client.Registration.ResolveAccountByKey()
if err != nil {
return nil, err
}
return reg, nil
}