mirror of
https://github.com/versity/versitygw.git
synced 2026-09-23 16:34:18 +00:00
Go 1.26 marks PKCS#1 v1.5 RSA encryption and direct ECDSA public-key coordinate access as deprecated. The IPA code path uses rsa.EncryptPKCS1v15 to wrap an AES session key for vault retrieval, and the OIDC JWKS parser rebuilt EC public keys by setting ecdsa.PublicKey.X and Y directly. This change replaces the deprecated RSA wrap with rsa.EncryptOAEP using SHA-256, which is the standard safe replacement for PKCS#1 v1.5 encryption and preserves the same protocol semantics for the IPA vault exchange. For EC JWKs, it reconstructs the raw uncompressed public point and parses it through ecdsa.ParseUncompressedPublicKey, which is the supported Go API for EC public keys and avoids mutating deprecated fields while preserving the exact mathematical key value. These changes do not alter the wire protocol or trust decisions; they only migrate to the supported stdlib APIs for equivalent behavior.
526 lines
12 KiB
Go
526 lines
12 KiB
Go
// Copyright 2025 Versity Software
|
|
// This file is 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
|
|
//
|
|
// http://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 auth
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/versity/versitygw/debuglogger"
|
|
)
|
|
|
|
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
|
|
rootAcc Account
|
|
}
|
|
|
|
var _ IAMService = &IpaIAMService{}
|
|
|
|
func NewIpaIAMService(rootAcc Account, host, vaultName, username, password string, isInsecure bool) (*IpaIAMService, error) {
|
|
ipa := IpaIAMService{
|
|
id: 0,
|
|
version: IpaVersion,
|
|
host: host,
|
|
vaultName: vaultName,
|
|
username: username,
|
|
password: password,
|
|
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,
|
|
Proxy: http.ProxyFromEnvironment,
|
|
}
|
|
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 := slices.Contains(vaultConfig.Wrapping_supported_algorithms, "aes-128-cbc")
|
|
|
|
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
|
|
PidNumber []string
|
|
}{}
|
|
|
|
err = ipa.rpc(req, &userResult)
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
|
|
uid, err := parseToInt(userResult.Uidnumber, "userID")
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
gid, err := parseToInt(userResult.Gidnumber, "groupID")
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
pId, err := parseToInt(userResult.PidNumber, "projectID")
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
|
|
account := Account{
|
|
Access: access,
|
|
Role: RoleUser,
|
|
UserID: uid,
|
|
GroupID: gid,
|
|
ProjectID: pId,
|
|
}
|
|
|
|
session_key := make([]byte, 16)
|
|
|
|
_, err = rand.Read(session_key)
|
|
if err != nil {
|
|
return account, fmt.Errorf("ipa cannot generate session key: %w", err)
|
|
}
|
|
|
|
encryptedKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, ipa.kraTransportKey, session_key, nil)
|
|
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(encryptedKey),
|
|
"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)
|
|
secretUnpaddedJson, 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(secretUnpaddedJson, &secret)
|
|
account.Secret = string(secret.Data)
|
|
|
|
return account, nil
|
|
}
|
|
|
|
// ResolveAccounts returns the subset of accessKeyIDs that do not exist.
|
|
func (ipa *IpaIAMService) ResolveAccounts(accessKeyIDs []string) ([]string, error) {
|
|
return resolveAccountsByLookup(accessKeyIDs, ipa.GetUserAccount)
|
|
}
|
|
|
|
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
|
|
|
|
const requestRetries = 3
|
|
|
|
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")
|
|
|
|
var resp *http.Response
|
|
for i := range requestRetries {
|
|
resp, err = ipa.client.Do(req)
|
|
if err == nil {
|
|
break
|
|
}
|
|
// Check for transient network errors
|
|
if isRetryable(err) {
|
|
time.Sleep(time.Second * time.Duration(i+1))
|
|
continue
|
|
}
|
|
return fmt.Errorf("login POST to %s failed: %w", req.URL, err)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("login POST to %s failed after retries: %w",
|
|
req.URL, err)
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
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
|
|
}
|
|
|
|
return json.Unmarshal(res.Result, value)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
debuglogger.IAMLogf("IPA request: %v", req)
|
|
httpReq.Header.Set("referer", fmt.Sprintf("%s/ipa", ipa.host))
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
var httpResp *http.Response
|
|
for i := range requestRetries {
|
|
httpResp, err = ipa.client.Do(httpReq)
|
|
if err == nil {
|
|
break
|
|
}
|
|
// Check for transient network errors
|
|
if isRetryable(err) {
|
|
time.Sleep(time.Second * time.Duration(i+1))
|
|
continue
|
|
}
|
|
return rpcResponse{}, fmt.Errorf("ipa request to %s failed: %w",
|
|
httpReq.URL, err)
|
|
}
|
|
if err != nil {
|
|
return rpcResponse{},
|
|
fmt.Errorf("ipa request to %s failed after retries: %w",
|
|
httpReq.URL, err)
|
|
}
|
|
|
|
defer httpResp.Body.Close()
|
|
|
|
bytes, err := io.ReadAll(httpResp.Body)
|
|
debuglogger.IAMLogf("IPA response (%v): %v", err, 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("%s: %w", string(result.Error), errRpc)
|
|
}
|
|
|
|
return rpcResponse{
|
|
Result: result.Result.Json,
|
|
Principal: result.Principal,
|
|
Id: result.Id,
|
|
Version: result.Version,
|
|
}, nil
|
|
}
|
|
|
|
func isRetryable(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
if errors.Is(err, io.EOF) {
|
|
return true
|
|
}
|
|
|
|
if err, ok := err.(net.Error); ok && err.Timeout() {
|
|
return true
|
|
}
|
|
|
|
if opErr, ok := err.(*net.OpError); ok {
|
|
if sysErr, ok := opErr.Err.(*syscall.Errno); ok {
|
|
if *sysErr == syscall.ECONNRESET {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
request := map[string]any{
|
|
"id": id,
|
|
"method": json.RawMessage(jmethod),
|
|
"params": []json.RawMessage{json.RawMessage(jargs), json.RawMessage(jdict)},
|
|
}
|
|
|
|
requestJSON, err := json.Marshal(request)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
return string(requestJSON), 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 := range n {
|
|
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
|
|
}
|
|
|
|
// parseToInt parses the first argument of input string slice
|
|
// to an integer. If slice is empty, it defaults to 0
|
|
func parseToInt(input []string, argName string) (int, error) {
|
|
if len(input) == 0 {
|
|
debuglogger.IAMLogf("empty %s slice: defaulting to 0", argName)
|
|
return 0, nil
|
|
}
|
|
|
|
id, err := strconv.Atoi(input[0])
|
|
if err != nil {
|
|
debuglogger.IAMLogf("failed to parse %s: %v", argName, err)
|
|
return 0, fmt.Errorf("invalid %s: %w", argName, err)
|
|
}
|
|
|
|
return id, nil
|
|
}
|