From ee315276f6d8aa93fd27758c0dffa613e828837c Mon Sep 17 00:00:00 2001 From: yhal-nesi <52471469+yhal-nesi@users.noreply.github.com> Date: Thu, 9 Jan 2025 11:30:23 +1300 Subject: [PATCH] Implement IPA IAM backend (#1005) feat: FreeIPA IAM implementation This implements the GetUserAccount() IAM support for accounts stored within FreeIPA service. This is not implementing any of the account management functions such as create, update, delete, list IAM accounts, so is not intended to allow versitygw to manage the IAM accounts within the FreeIPA service. --------- Co-authored-by: Yuriy Halytskyy --- auth/iam.go | 9 + auth/iam_ipa.go | 421 ++++++++++++++++++++++++++++++++++++++++++ cmd/versitygw/main.go | 46 +++++ 3 files changed, 476 insertions(+) create mode 100644 auth/iam_ipa.go diff --git a/auth/iam.go b/auth/iam.go index 0a5b950f..7f6d21a0 100644 --- a/auth/iam.go +++ b/auth/iam.go @@ -124,6 +124,12 @@ type Opts struct { CacheDisable bool CacheTTL int CachePrune int + IpaHost string + IpaVaultName string + IpaUser string + IpaPassword string + IpaInsecure bool + IpaDebug bool } func New(o *Opts) (IAMService, error) { @@ -149,6 +155,9 @@ func New(o *Opts) (IAMService, error) { o.VaultMountPath, o.VaultRootToken, o.VaultRoleId, o.VaultRoleSecret, o.VaultServerCert, o.VaultClientCert, o.VaultClientCertKey) fmt.Printf("initializing Vault IAM with %q\n", o.VaultEndpointURL) + case o.IpaHost != "": + svc, err = NewIpaIAMService(o.RootAccount, o.IpaHost, o.IpaVaultName, o.IpaUser, o.IpaPassword, o.IpaInsecure, o.IpaDebug) + fmt.Printf("initializing IPA IAM with %q\n", o.IpaHost) default: // if no iam options selected, default to the single user mode fmt.Println("No IAM service configured, enabling single account mode") diff --git a/auth/iam_ipa.go b/auth/iam_ipa.go new file mode 100644 index 00000000..6a9b4151 --- /dev/null +++ b/auth/iam_ipa.go @@ -0,0 +1,421 @@ +package auth + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/http/cookiejar" + "net/url" + "strconv" + "strings" +) + +const IpaVersion = "2.254" + +type IpaIAMService struct { + client http.Client + id int + version string + host string + vaultName string + username string + password string + kraTransportKey *rsa.PublicKey + debug bool + rootAcc Account +} + +var _ IAMService = &IpaIAMService{} + +func NewIpaIAMService(rootAcc Account, host, vaultName, username, password string, isInsecure, debug bool) (*IpaIAMService, error) { + + ipa := IpaIAMService{ + id: 0, + version: IpaVersion, + host: host, + vaultName: vaultName, + username: username, + password: password, + debug: debug, + rootAcc: rootAcc, + } + jar, err := cookiejar.New(nil) + if err != nil { + // this should never happen + return nil, fmt.Errorf("cookie jar creation: %w", err) + } + + mTLSConfig := &tls.Config{InsecureSkipVerify: isInsecure} + tr := &http.Transport{ + TLSClientConfig: mTLSConfig, + } + ipa.client = http.Client{Jar: jar, Transport: tr} + + err = ipa.login() + if err != nil { + return nil, fmt.Errorf("ipa login failed: %w", err) + } + + req, err := ipa.newRequest("vaultconfig_show/1", []string{}, map[string]any{"all": true}) + if err != nil { + return nil, fmt.Errorf("ipa vaultconfig_show: %w", err) + } + vaultConfig := struct { + Kra_Server_Server []string + Transport_Cert Base64EncodedWrapped + Wrapping_default_algorithm string + Wrapping_supported_algorithms []string + }{} + err = ipa.rpc(req, &vaultConfig) + if err != nil { + return nil, fmt.Errorf("ipa vault config: %w", err) + } + + cert, err := x509.ParseCertificate(vaultConfig.Transport_Cert) + if err != nil { + return nil, fmt.Errorf("ipa cannot parse vault certificate: %w", err) + } + + ipa.kraTransportKey = cert.PublicKey.(*rsa.PublicKey) + + isSupported := false + for _, algo := range vaultConfig.Wrapping_supported_algorithms { + if algo == "aes-128-cbc" { + isSupported = true + break + } + } + + if !isSupported { + return nil, fmt.Errorf("IPA vault does not support aes-128-cbc. Only %v supported", vaultConfig.Wrapping_supported_algorithms) + } + return &ipa, nil +} + +func (ipa *IpaIAMService) CreateAccount(account Account) error { + return fmt.Errorf("not implemented") +} + +func (ipa *IpaIAMService) GetUserAccount(access string) (Account, error) { + if access == ipa.rootAcc.Access { + return ipa.rootAcc, nil + } + + req, err := ipa.newRequest("user_show/1", []string{access}, map[string]any{}) + if err != nil { + return Account{}, fmt.Errorf("ipa user_show: %w", err) + } + + userResult := struct { + Gidnumber []string + Uidnumber []string + }{} + err = ipa.rpc(req, &userResult) + if err != nil { + return Account{}, err + } + + uid, err := strconv.Atoi(userResult.Uidnumber[0]) + if err != nil { + return Account{}, fmt.Errorf("ipa uid invalid: %w", err) + } + gid, err := strconv.Atoi(userResult.Gidnumber[0]) + if err != nil { + return Account{}, fmt.Errorf("ipa gid invalid: %w", err) + } + + account := Account{ + Access: access, + Role: RoleUser, + UserID: uid, + GroupID: gid, + } + + session_key := make([]byte, 16) + rand.Read(session_key) + encrypted_key, err := rsa.EncryptPKCS1v15(rand.Reader, ipa.kraTransportKey, session_key) + if err != nil { + return account, fmt.Errorf("ipa vault secret retrieval: %w", err) + } + req, err = ipa.newRequest("vault_retrieve_internal/1", []string{ipa.vaultName}, + map[string]any{"username": access, + "session_key": Base64EncodedWrapped(encrypted_key), + "wrapping_algo": "aes-128-cbc"}) + if err != nil { + return Account{}, fmt.Errorf("ipa vault_retrieve_internal: %w", err) + } + data := struct { + Vault_data Base64EncodedWrapped + Nonce Base64EncodedWrapped + }{} + err = ipa.rpc(req, &data) + if err != nil { + return account, err + } + + aes, err := aes.NewCipher(session_key) + if err != nil { + return account, fmt.Errorf("ipa cannot create AES cipher: %w", err) + } + cbc := cipher.NewCBCDecrypter(aes, data.Nonce) + cbc.CryptBlocks(data.Vault_data, data.Vault_data) + secret_unpadded_json, err := pkcs7Unpad(data.Vault_data, 16) + if err != nil { + return account, fmt.Errorf("ipa cannot unpad decrypted result: %w", err) + } + + secret := struct { + Data Base64Encoded + }{} + json.Unmarshal(secret_unpadded_json, &secret) + account.Secret = string(secret.Data) + + fmt.Printf("%v\n", account) + return account, nil +} + +func (ipa *IpaIAMService) UpdateUserAccount(access string, props MutableProps) error { + return fmt.Errorf("not implemented") +} + +func (ipa *IpaIAMService) DeleteUserAccount(access string) error { + return fmt.Errorf("not implemented") +} + +func (ipa *IpaIAMService) ListUserAccounts() ([]Account, error) { + return []Account{}, fmt.Errorf("not implemented") +} + +func (ipa *IpaIAMService) Shutdown() error { + return nil +} + +// Implementation + +func (ipa *IpaIAMService) login() error { + form := url.Values{} + form.Set("user", ipa.username) + form.Set("password", ipa.password) + + req, err := http.NewRequest( + "POST", + fmt.Sprintf("%s/ipa/session/login_password", ipa.host), + strings.NewReader(form.Encode())) + if err != nil { + return err + } + + req.Header.Set("referer", fmt.Sprintf("%s/ipa", ipa.host)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := ipa.client.Do(req) + if err != nil { + return err + } + + if resp.StatusCode == 401 { + return errors.New("cannot login to FreeIPA: invalid credentials") + } + + if resp.StatusCode != 200 { + return fmt.Errorf("cannot login to FreeIPA: status code %d", resp.StatusCode) + } + return nil +} + +type rpcRequest = string + +type rpcResponse struct { + Result json.RawMessage + Principal string + Id int + Version string +} + +func (p rpcResponse) String() string { + return string(p.Result) +} + +var errRpc = errors.New("IPA RPC error") + +func (ipa *IpaIAMService) rpc(req rpcRequest, value any) error { + + err := ipa.login() + if err != nil { + return err + } + + res, err := ipa.rpcInternal(req) + if err != nil { + return err + } + err = json.Unmarshal(res.Result, value) + return err +} + +func (ipa *IpaIAMService) rpcInternal(req rpcRequest) (rpcResponse, error) { + + httpReq, err := http.NewRequest("POST", + fmt.Sprintf("%s/ipa/session/json", ipa.host), + strings.NewReader(req)) + if err != nil { + return rpcResponse{}, err + } + + ipa.log(fmt.Sprintf("%v\n", req)) + httpReq.Header.Set("referer", fmt.Sprintf("%s/ipa", ipa.host)) + httpReq.Header.Set("Content-Type", "application/json") + + httpResp, err := ipa.client.Do(httpReq) + if err != nil { + return rpcResponse{}, err + } + + bytes, err := io.ReadAll(httpResp.Body) + ipa.log(fmt.Sprintf("%v\n", string(bytes))) + if err != nil { + return rpcResponse{}, err + } + + result := struct { + Result struct { + Json json.RawMessage `json:"result"` + Value string `json:"value"` + Summary any `json:"summary"` + } `json:"result"` + Error json.RawMessage `json:"error"` + Id int `json:"id"` + Principal string `json:"principal"` + Version string `json:"version"` + }{} + + err = json.Unmarshal(bytes, &result) + if err != nil { + return rpcResponse{}, err + } + if string(result.Error) != "null" { + return rpcResponse{}, fmt.Errorf("%w: %s", errRpc, string(result.Error)) + } + + response := rpcResponse{ + Result: result.Result.Json, + Principal: result.Principal, + Id: result.Id, + Version: result.Version, + } + return response, nil +} + +func (ipa *IpaIAMService) newRequest(method string, args []string, dict map[string]any) (rpcRequest, error) { + + id := ipa.id + ipa.id++ + + dict["version"] = ipa.version + + jmethod, errMethod := json.Marshal(method) + jargs, errArgs := json.Marshal(args) + jdict, errDict := json.Marshal(dict) + + err := errors.Join(errMethod, errArgs, errDict) + if err != nil { + return "", fmt.Errorf("ipa request invalid: %w", err) + } + + return fmt.Sprintf(`{ + "id": %d, + "method": %s, + "params": [ + %s, + %s + ] + } + `, id, jmethod, jargs, jdict), nil +} + +// pkcs7Unpad validates and unpads data from the given bytes slice. +// The returned value will be 1 to n bytes smaller depending on the +// amount of padding, where n is the block size. +func pkcs7Unpad(b []byte, blocksize int) ([]byte, error) { + if blocksize <= 0 { + return nil, errors.New("invalid blocksize") + } + if len(b) == 0 { + return nil, errors.New("invalid PKCS7 data (empty or not padded)") + } + if len(b)%blocksize != 0 { + return nil, errors.New("invalid padding on input") + } + c := b[len(b)-1] + n := int(c) + if n == 0 || n > len(b) { + return nil, errors.New("invalid padding on input") + } + for i := 0; i < n; i++ { + if b[len(b)-n+i] != c { + return nil, errors.New("invalid padding on input") + } + } + return b[:len(b)-n], nil +} + +/* +e.g. + + "value" { + "__base64__": "aGVsbG93b3JsZAo=" + } +*/ +type Base64EncodedWrapped []byte + +func (b *Base64EncodedWrapped) UnmarshalJSON(data []byte) error { + intermediate := struct { + Base64 string `json:"__base64__"` + }{} + err := json.Unmarshal(data, &intermediate) + if err != nil { + return err + } + *b, err = base64.StdEncoding.DecodeString(intermediate.Base64) + return err +} + +func (b *Base64EncodedWrapped) MarshalJSON() ([]byte, error) { + intermediate := struct { + Base64 string `json:"__base64__"` + }{Base64: base64.StdEncoding.EncodeToString(*b)} + return json.Marshal(intermediate) +} + +/* +e.g. + + "value": "aGVsbG93b3JsZAo=" +*/ +type Base64Encoded []byte + +func (b *Base64Encoded) UnmarshalJSON(data []byte) error { + var intermediate string + err := json.Unmarshal(data, &intermediate) + if err != nil { + return err + } + *b, err = base64.StdEncoding.DecodeString(intermediate) + return err +} + +func (ipa *IpaIAMService) log(msg string) { + if ipa.debug { + log.Print(msg) + } +} diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go index 018573de..b501c8c8 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -74,6 +74,9 @@ var ( metricsService string statsdServers string dogstatsServers string + ipaHost, ipaVaultName string + ipaUser, ipaPassword string + ipaInsecure, ipaDebug bool ) var ( @@ -206,6 +209,7 @@ func initFlags() []cli.Flag { &cli.BoolFlag{ Name: "debug", Usage: "enable debug output", + Value: false, EnvVars: []string{"VGW_DEBUG"}, Destination: &debug, }, @@ -506,6 +510,42 @@ func initFlags() []cli.Flag { Aliases: []string{"mds"}, Destination: &dogstatsServers, }, + &cli.StringFlag{ + Name: "ipa-host", + Usage: "FreeIPA server url e.g. https://ipa.example.test", + EnvVars: []string{"VGW_IPA_HOST"}, + Destination: &ipaHost, + }, + &cli.StringFlag{ + Name: "ipa-vault-name", + Usage: "A name of the user vault containing their secret", + EnvVars: []string{"VGW_IPA_VAULT_NAME"}, + Destination: &ipaVaultName, + }, + &cli.StringFlag{ + Name: "ipa-user", + Usage: "Username used to connect to FreeIPA. Needs permissions to read user vault contents", + EnvVars: []string{"VGW_IPA_USER"}, + Destination: &ipaUser, + }, + &cli.StringFlag{ + Name: "ipa-password", + Usage: "Password of the user used to connect to FreeIPA.", + EnvVars: []string{"VGW_IPA_PASSWORD"}, + Destination: &ipaPassword, + }, + &cli.BoolFlag{ + Name: "ipa-insecure", + Usage: "Verify TLS certificate of FreeIPA server. Default is 'true'.", + EnvVars: []string{"VGW_IPA_INSECURE"}, + Destination: &ipaInsecure, + }, + &cli.BoolFlag{ + Name: "ipa-debug", + Usage: "FreeIPA IAM debug output", + EnvVars: []string{"VGW_IPA_DEBUG"}, + Destination: &ipaDebug, + }, } } @@ -623,6 +663,12 @@ func runGateway(ctx context.Context, be backend.Backend) error { CacheDisable: iamCacheDisable, CacheTTL: iamCacheTTL, CachePrune: iamCachePrune, + IpaHost: ipaHost, + IpaVaultName: ipaVaultName, + IpaUser: ipaUser, + IpaPassword: ipaPassword, + IpaInsecure: ipaInsecure, + IpaDebug: ipaDebug, }) if err != nil { return fmt.Errorf("setup iam: %w", err)