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)