Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vault 1.8.1 acl templating breaks with JWT #12336

Closed
evilmog opened this issue Aug 17, 2021 · 11 comments
Closed

Vault 1.8.1 acl templating breaks with JWT #12336

evilmog opened this issue Aug 17, 2021 · 11 comments
Labels
bug Used to indicate a potential bug cryptosec secret/pki

Comments

@evilmog
Copy link

evilmog commented Aug 17, 2021

Describe the bug
A clear and concise description of what the bug is.

To Reproduce
Steps to reproduce the behavior:
Configure vault with JWT and then template under PKI or SSH Certificates the following allowed domain template:

{{identity.entity.aliases.auth_jwt_31588259.metadata.name}}

This matches the documentation https://learn.hashicorp.com/tutorials/vault/policy-templating

the vault ssh config is something similar to one configured by this python script:

import hvac
import os
import json
import requests
import time
from getpass import getpass

vault_base = "redacted"

shares = 5
threshold = 3

# Access Levels to Create
access_levels = [ "level1", "level2", "level3"]
# this is the prefix for groups to be created
group_prefix = "redacted_"
ssh_role_prefix = "ssh-jwt-"
access_policies = {}

#JWT Config Info
bound_issuer="redacted"
jwks_url="redacted"
oidc_response_mode = "form_post"
user_claim = "emailAddress"
groups_claim = "blueGroups"
preferred_username = "uid"
emailAddress = "name"
allowed_redirect_uris = []
claim_mappings = { }
claim_mappings["emailAddress"] ="name"
claim_mappings["preferred_username"] ="uid"

# OIDC Client ID from onboarding
bound_audiences = ["redacted"]

# SSH Config Into - TTL
ssh_ttl = 3600
ssh_token_period = 10

# PKI Config
# set the config options
# Root Tuning
root_pki_tune_options_dict = {}
root_pki_tune_options_dict['max_lease_ttl'] = "87601h"
root_pki_tune_options_dict['default_lease_ttl'] = "87600h"
# Intermediate Tuning
intermediate_pki_tune_options_dict = {}
intermediate_pki_tune_options_dict['max_lease_ttl'] = "43849h"
intermediate_pki_tune_options_dict['default_lease_ttl'] = "43848h"

# Root CA Config
pki_root_name = "pki_root"
root_ca_params = {}
root_ca_params['ttl'] = "87600h"
root_ca_params['max_lease_ttl'] = "87601h"
root_ca_params['cn'] = "Redacted"
root_ca_params['key_type'] = "rsa"
root_ca_params['key_bits'] = "4096"
root_ca_params['max_path_length'] = 3
root_ca_params['type'] = "internal"
root_ca_params['OU'] = "Redacted" 
root_ca_params['Organization'] = "Redacted"
root_ca_params['Country'] = "US" 
root_ca_params['Locality'] = "Redacted"
root_ca_params['province'] = "Redacted"

# Intermediate CA Config

int_ca_params = {}
pki_int_name = "tester_pki"
int_ca_params['ttl'] = "43848h"
int_ca_params['max_lease_ttl'] = "87601h"
int_ca_params['cn'] = "redacted"
int_ca_params['key_type'] = "rsa"
int_ca_params['key_bits'] = "4096"
int_ca_params['max_path_length'] = 3
int_ca_params['type'] = "internal"
int_ca_params['OU'] = "redacted" 
int_ca_params['Organization'] = "redacted"
int_ca_params['Country'] = "US" 
int_ca_params['Locality'] = "redacted"
int_ca_params['province'] = "GA"

client = hvac.Client(vault_base)

if client.sys.is_initialized() is False:
    # initialize
    result = client.sys.initialize(shares, threshold)

    # get token from initialized vault
    root_token = result['root_token']
    # get recovery keys
    keys = result['keys']

    # unseal with individual keys
    client.sys.submit_unseal_key(key=keys[0])
    client.sys.submit_unseal_key(key=keys[1])
    client.sys.submit_unseal_key(key=keys[2])

    if not os.path.exists(os.path.expanduser('~/.vault')):
        os.makedirs(os.path.expanduser('~/.vault'))

    with open(os.path.expanduser("~/.vault/test_vault.unseal"),'w') as out:
        for key in keys:
            out.write(key + '\n')
    out.close()

    with open(os.path.expanduser("~/.vault/test_vault.root"), 'w') as out:
        out.write(root_token + '\n')
    out.close()

    # sleep and wait for initialization
    time.sleep(10)
else:
    print("Sorry this vault is already initialized")
    exit()

# create vault header token
vault_headers = {'X-Vault-Token': root_token}

# login with new root token
client = hvac.Client(url=vault_base, token=root_token)

# Enable JWT Auth
client.sys.enable_auth_method(method_type='jwt')

# Configure JWT Auth
client.auth.jwt.configure(
	bound_issuer=bound_issuer,
	oidc_response_mode=oidc_response_mode,
	oidc_response_types="id_token",
	jwks_url=jwks_url
)

# list the authentication methods
auth_methods = client.sys.list_auth_methods()

# Get the JWT Token Accessor for later use
jwt_token_accessor = auth_methods['data']['jwt/']['accessor']

# create ssh-client-signer
client.sys.enable_secrets_engine(
    backend_type='ssh',
    path='ssh-client-signer',
    default_lease_ttl=2764800,
    max_lease_ttl=2764800
)

ssh_ca_pub_payload_dict = {}

if os.path.exists(os.path.expanduser('~/.vault/ssh_ca.json')):
	ssh_ca_pub_payload_dict['generate_signing_key'] = "false"
	with open(os.path.expanduser('~/.vault/ssh_ca.json')) as json_file:
		ssh_ca_text = json_file.read()
		ssh_ca_json = json.loads(ssh_ca_text)
	ssh_ca_pub_payload_dict['private_key'] = ssh_ca_json['private_key']
	ssh_ca_pub_payload_dict['public_key'] = ssh_ca_json['public_key']
	json_file.close()
else:
	ssh_ca_pub_payload_dict['generate_signing_key'] = "true"

requests.post(vault_base + "/v1/ssh-client-signer/config/ca", headers=vault_headers, data = json.dumps(ssh_ca_pub_payload_dict))
ssh_ca_pub = requests.get(vault_base + "/v1/ssh-client-signer/public_key")
with open(os.path.expanduser("~/.vault/ssh_ca.pub"), 'w') as out:
	out.write(ssh_ca_pub.text)
out.close()

# PKI Root

# create PKI Mount Point - Root
pki_data = {}
pki_data['type'] = "pki"
root_create = requests.post(vault_base + "/v1/sys/mounts/" + pki_root_name, headers=vault_headers, data=json.dumps(pki_data))
root_tune = requests.post(vault_base + "/v1/sys/mounts/" + pki_root_name + "/tune", headers=vault_headers, data=json.dumps(root_pki_tune_options_dict))
# create PKI Mount Point - Tester PKI
tester_create = requests.post(vault_base + "/v1/sys/mounts/" + pki_int_name, headers=vault_headers, data=json.dumps(pki_data))
tester_tune = requests.post(vault_base + "/v1/sys/mounts/" + pki_int_name +"/tune", headers=vault_headers, data=json.dumps(intermediate_pki_tune_options_dict))

# create PKI directory
if not os.path.exists(os.path.expanduser('~/.vault/pki')):
    os.makedirs(os.path.expanduser('~/.vault/pki'))
if not os.path.exists(os.path.expanduser('~/.vault/pki/root')):
    os.makedirs(os.path.expanduser('~/.vault/pki/root'))
if not os.path.exists(os.path.expanduser('~/.vault/pki/tester')):
    os.makedirs(os.path.expanduser('~/.vault/pki/tester'))

# Generate the Root CA
root_generate = requests.post(vault_base + "/v1/" + pki_root_name + "/root/generate/internal", headers=vault_headers, data=json.dumps(root_ca_params))
root_ca_pub = json.loads(root_generate.text)['data']['certificate']
# write ca to file
with open(os.path.expanduser("~/.vault/pki/root/ca.pub"), 'w') as out:
	out.write(root_ca_pub)
out.close()

# Generate the Intermediate CA
int_generate = requests.post(vault_base + "/v1/" + pki_int_name + "/intermediate/generate/internal", headers=vault_headers, data=json.dumps(int_ca_params))
int_ca_csr = json.loads(int_generate.text)['data']['csr']
# write csr to file
with open(os.path.expanduser("~/.vault/pki/tester/ca.csr"), "w") as out:
	out.write(int_ca_csr)
out.close()

# generate csr dict
csr_dict = {}
csr_dict['csr'] = int_ca_csr
csr_dict['ttl'] = int_ca_params['ttl']
# sign intermediate
root_sign = requests.post(vault_base + "/v1/" + pki_root_name + "/root/sign-intermediate", headers=vault_headers,data=json.dumps(csr_dict))
signed_intermediate=json.loads(root_sign.text)['data']['certificate'] + "\n" + json.loads(root_sign.text)['data']['issuing_ca']
# write signed to file
with open(os.path.expanduser("~/.vault/pki/tester/ca.chain"), "w") as out:
	out.write(int_ca_csr)
out.close()
with open(os.path.expanduser("~/.vault/pki/tester/ca.pub"), "w") as out:
	out.write(json.loads(root_sign.text)['data']['certificate'])
out.close()

# set signed cert
signed_dict = {}
signed_dict['format']= 'pem_bundle'
signed_dict['certificate'] = signed_intermediate

post_intermediate = requests.post(vault_base + "/v1/" + pki_int_name + "/intermediate/set-signed", headers=vault_headers,data=json.dumps(signed_dict))


# Create Default Policy
# start policy block
jwt_default_policy_block = '''
path "sys/mounts" {
  capabilities = ["list", "read"]
}

path "secrets/data/vpn/'''+ pki_int_name +'''" {
  capabilities = ["read", "list"]
}

path "''' + pki_int_name +'''/issue/*" {
	capabilities = ["create", "update"]
}

path "''' + pki_int_name +'''/certs" {
	capabilities = ["list"]
}

path "''' + pki_int_name +'''/revoke" {
	capabilities = ["create", "update"]
}

path "''' + pki_int_name +'''/tidy" {
	capabilities = ["create", "update"]
}

path "''' + pki_int_name +'''/cert/ca" {
	capabilities = ["read"]
}

path "''' + pki_root_name + '''/cert/ca" {
	capabilities = ["read"]
}

path "ssh-client-signer/*" {
   capabilities = ["list", "read"]
}

path "ssh-client-signer/config/ca" {
  capabilities = ["read", "list"]
}

# DEFAULT Policy Entries
# Allow tokens to look up their own properties
path "auth/token/lookup-self" {
    capabilities = ["read"]
}

# Allow tokens to revoke themselves
path "auth/token/revoke-self" {
    capabilities = ["update"]
}

# Allow a token to look up its own capabilities on a path
path "sys/capabilities-self" {
    capabilities = ["update"]
}

# Allow a token to look up its own entity by id or name
path "identity/entity/id/{{identity.entity.id}}" {
  capabilities = ["read"]
}
path "identity/entity/name/{{identity.entity.name}}" {
  capabilities = ["read"]
}

# Allow a token to look up its resultant ACL from all policies. This is useful
# for UIs. It is an internal path because the format may change at any time
# based on how the internal ACL features and capabilities change.
path "sys/internal/ui/resultant-acl" {
    capabilities = ["read"]
}

# Allow a token to renew a lease via lease_id in the request body; old path for
# old clients, new path for newer
path "sys/renew" {
    capabilities = ["update"]
}
path "sys/leases/renew" {
    capabilities = ["update"]
}

# Allow looking up lease properties. This requires knowing the lease ID ahead
# of time and does not divulge any sensitive information.
path "sys/leases/lookup" {
    capabilities = ["update"]
}

# Allow a token to wrap arbitrary values in a response-wrapping token
path "sys/wrapping/wrap" {
    capabilities = ["update"]
}

# Allow a token to look up the creation time and TTL of a given
# response-wrapping token
path "sys/wrapping/lookup" {
    capabilities = ["update"]
}

# Allow a token to unwrap a response-wrapping token. This is a convenience to
# avoid client token swapping since this is also part of the response wrapping
# policy.
path "sys/wrapping/unwrap" {
    capabilities = ["update"]
}

# Allow general purpose tools
path "sys/tools/hash" {
    capabilities = ["update"]
}
path "sys/tools/hash/*" {
    capabilities = ["update"]
}

# Allow checking the status of a Control Group request if the user has the
# accessor
path "sys/control-group/request" {
    capabilities = ["update"]
}
'''
# end policy block

# create default policy
client.sys.create_or_update_policy(
    name='jwt-default-policy',
    policy=jwt_default_policy_block,
)

# Create Customized Policy
#path "ssh-client-signer/sign/ssh-jwt-advsim" {
#  capabilities = ["create", "update", "list", "read"]
#}

# loop over access policy levels to create names policies
for level in access_levels:
	# construct policy name
	access_policy_name = 'ssh-ca-' + level + '-policy'
	assigned_policies = ["jwt-default-policy"]
	external_group_name = group_prefix + level
	# programatic policy development
	access_policies[level] = "path \"ssh-client-signer/sign/ssh-jwt-" + level +"\" {\n"
	access_policies[level] += "  capabilities = [\"create\", \"update\", \"list\", \"read\"]\n}"

	# create the policy itself
	client.sys.create_or_update_policy(
		name=access_policy_name,
		policy=access_policies[level],
	)
	# JWT
	client.auth.jwt.create_role(
		name=level,
		role_type='jwt',
		allowed_redirect_uris = allowed_redirect_uris,
		user_claim = user_claim,
		bound_audiences = bound_audiences,
		groups_claim = groups_claim,
		token_period = ssh_token_period,
		token_ttl = ssh_ttl,
		claim_mappings = claim_mappings,
		token_policies = assigned_policies
	)

	# create external groups
	client.secrets.identity.create_or_update_group_by_name(
        name=level,
        group_type = "external",
        policies = access_policy_name,
	)
	# get group id of external group
	read_response = client.secrets.identity.read_group_by_name(name=level)
	group_id = read_response['data']['id']

	# create internal group
	client.secrets.identity.create_or_update_group_by_name(
		name=external_group_name,
		group_type = "internal",
		policies = access_policy_name,
		member_group_ids = group_id,
    )

	ssh_role_name = ssh_role_prefix + level
	default_username = "zone-" + level
	ssh_role_dict = {}
	ssh_role_dict['name'] = ssh_role_name
	ssh_role_dict['allowed_extensions'] = "permit-pty,permit-port-forwarding,permit-agent-forwarding,permit-X11-forwarding"
	ssh_role_dict['default_extensions'] = {"permit-X11-forwarding": "", "permit-agent-forwarding": "", "permit-port-forwarding": "", "permit-pty": ""}
	ssh_role_dict['allow_bare_domains'] = "false"
	ssh_role_dict['allow_host_certificates'] = "false"
	ssh_role_dict['allow_subdomains'] = "false"
	ssh_role_dict['allow_user_certificates'] = "true"
	ssh_role_dict['allow_user_key_ids'] = "false"
	ssh_role_dict['allowed_critical_options'] = ""
	ssh_role_dict['allowed_domains'] = "*"
	ssh_role_dict['allowed_users_template'] = "true"
	ssh_role_dict['key_id_format'] = "{{token_display_name}}"
	ssh_role_dict['key_type'] = "ca"
	ssh_role_dict['max_ttl'] = "1209600"
	ssh_role_dict['ttl'] = "86400"
	ssh_role_dict['default_username'] = default_username
	allowed_users = ('zone-' + level + ',' + level + ',{{identity.entity.aliases.auth_' + jwt_token_accessor + '.metadata.name}}')
	ssh_role_dict['allowed_users'] = allowed_users
	requests.post(vault_base + "/v1/ssh-client-signer/roles/" + ssh_role_name , headers=vault_headers, data = json.dumps(ssh_role_dict))

ta_text=""
# check to see if ta.key exists and if so provision it to secretstore
if os.path.exists(os.path.expanduser('~/.vault/ta.key')):
	with open(os.path.expanduser('~/.vault/ta.key')) as ta_file:
		ta_text = ta_file.read()
	ta_file.close()
	options_dict = {}
	options_dict['version'] = 2
	client.sys.enable_secrets_engine(backend_type='kv',options=options_dict,path='secrets',)
	ta_dict = {}
	ta_dict['options'] = {}
	ta_dict['data'] = {}
	ta_dict['options']['cas'] = 0
	ta_dict['data']['ta_key'] = ta_text
	ta_json=json.dumps(ta_dict)
	requests.post(vault_base + "/v1/secrets/data/vpn/" + pki_int_name, headers=vault_headers, data=ta_json)

role_tester_vpn ={}
role_tester_vpn['allow_any_name'] = "false"
role_tester_vpn['allow_bare_domains'] = "false"
role_tester_vpn['allow_glob_domains'] = "false"
role_tester_vpn['allow_ip_sans'] = "true"
role_tester_vpn['allow_localhost'] = "false"
role_tester_vpn['allow_subdomains'] = "false"
role_tester_vpn['allowed_domains'] = ["{{" + jwt_token_accessor + "}}"]
role_tester_vpn['allowed_domains_template'] = "true"
role_tester_vpn['allow_token_displayname'] = "false"
role_tester_vpn['allow_other_sans'] = []
role_tester_vpn['allow_serial_numbers'] = []
role_tester_vpn['basic_constraints_valid_for_non_ca'] = "false"
role_tester_vpn['client_flag'] = "true"
role_tester_vpn['code_signing_flag'] = "false"
role_tester_vpn['email_protection_flag'] = "false"
role_tester_vpn['enforce_hostnames'] = "true"
role_tester_vpn['key_bits'] = "4096"
role_tester_vpn['key_type'] = "rsa"
role_tester_vpn['key_usage'] = ["DigitalSignature","KeyAgreement","KeyEncipherment"]
role_tester_vpn['max_ttl'] = "1209600"
role_tester_vpn['ttl'] = "1209600"
role_tester_vpn['no_store'] = "false"
role_tester_vpn['require_cn'] = "true"
role_tester_vpn['use_csr_common_name'] = "true"
role_tester_vpn['use_csr_sans'] = "true"
requests.post(vault_base + "/v1/" + pki_int_name + "/roles/" + "testervpn", headers=vault_headers, data=json.dumps(role_tester_vpn))

Expected behavior
A certificate issued from user@domain.com instead an error is given

{'errors': ['common name redacted@redacted.com not allowed by this role']}

the only fix was to downgrade to 1.7.3

Environment:

  • Vault Server Version (retrieve with vault status): 1.8.1 (fixed in 1.7.3)
  • Vault CLI Version (retrieve with vault version): Vault v1.8.1 (4b0264f)
  • Server Operating System/Architecture: Ubuntu 20.04 LTS

Vault server configuration file(s):

ui = true

listener "tcp" {
  address       = "0.0.0.0:8200"
  cluster_address = "0.0.0.0:8201"
  tls_cert_file = "/etc/certs/vault.crt"
  tls_key_file  = "/etc/certs/vault.key"
  tls_disable_client_certs = true
}

storage "raft" {
  path = "/etc/raft"
  node_id = "xft_atl_vault_1"
}

seal "transit" {
  address            = "[redacted]"
  token              = "[redacted]"
  disable_renewal    = "false"
  key_name           = "atl-vault-autounseal"
  mount_path         = "transit/"
}

cluster_addr = "[redacted]"
api_addr = "[redacted]"

Additional context
Add any other context about the problem here.

Worked perfect in 1.7.3, doesn't work in 1.8.1

@pmmukh pmmukh added auth/cert Authentication - certificates bug Used to indicate a potential bug core Issues and Pull-Requests specific to Vault Core labels Sep 1, 2021
@pmmukh
Copy link
Contributor

pmmukh commented Sep 7, 2021

Hi @evilmog! Thanks for submitting this issue! Could you possibly give a smaller set of steps required to reproduce this issue ? The python script looks fairly large and complex, wondering if a few cli/api commands chained together could be used to recreate this issue. If not and everything done in the python script is necessary to recreate the bug, could you write down some details as to what is going on in the script and how it is triggering the bug exactly ?

@evilmog
Copy link
Author

evilmog commented Sep 7, 2021 via email

@pmmukh
Copy link
Contributor

pmmukh commented Sep 7, 2021

ah perfect, having that broken down like that is super helpful for me, thank you!! Follow up questions on the script:

  • What is the python version needed to run it?
  • Beyond updating the redacted variables and starting up a dev mode vault server on 127.0.0.1:8200, should I need to do anything else to be able to reproduce this ?

@evilmog
Copy link
Author

evilmog commented Sep 8, 2021 via email

@pmmukh
Copy link
Contributor

pmmukh commented Sep 9, 2021

thanks for the additional details! I will try to repro this shortly keeping the advice in mind, let you know here if I run into any issues!

@evilmog
Copy link
Author

evilmog commented Sep 10, 2021 via email

@pmmukh
Copy link
Contributor

pmmukh commented Sep 15, 2021

Sorry, I haven't gotten around to doing a repro of this yet, but just wanted to check, did the things you were planning to try in your last message end up resolving this or shedding any more light on it ?

@evilmog
Copy link
Author

evilmog commented Sep 16, 2021 via email

@DaspawnW
Copy link
Contributor

DaspawnW commented Sep 21, 2021

Hi guys,

in my opinion its not even required to go via the JWT or OIDC option. We discovered the same bug and it seems an allowed_common_name with the mail address as value is already enough to break it.

Working scenario on HC Vault 1.7.3 / 1.7.4:

docker run --rm -p 8200:8200 --cap-add=IPC_LOCK -e 'VAULT_DEV_ROOT_TOKEN_ID=myroot' -d vault:1.7.3

export VAULT_ADDR='http://0.0.0.0:8200'
export VAULT_TOKEN="myroot"

vault secrets enable pki
vault secrets tune -max-lease-ttl=8760h pki
vault write pki/root/generate/internal common_name=test.com ttl=8760h

vault write pki/roles/example allowed_domains=test@test.com allow_bare_domains=true
vault write pki/issue/example common_name=test@test.com

Not working scenario on HC Vault 1.8.2:

docker run --rm -p 8200:8200 --cap-add=IPC_LOCK -e 'VAULT_DEV_ROOT_TOKEN_ID=myroot' vault:1.8.2

export VAULT_ADDR='http://0.0.0.0:8200'
export VAULT_TOKEN="myroot"

vault secrets enable pki
vault secrets tune -max-lease-ttl=8760h pki
vault write pki/root/generate/internal common_name=test.com ttl=8760h

vault write pki/roles/example allowed_domains=test@test.com allow_bare_domains=true
vault write pki/issue/example common_name=test@test.com

Error writing data to pki/issue/example: Error making API request.

URL: PUT http://0.0.0.0:8200/v1/pki/issue/example
Code: 400. Errors:

* common name test@test.com not allowed by this role

@pmmukh
Copy link
Contributor

pmmukh commented Sep 21, 2021

Thanks so much for the repro steps @DaspawnW , can confirm that I just tried those, and was able to repro the problem on 1.8x and see that it doesn't exist in 1.7x.

@pmmukh pmmukh added secret/pki cryptosec and removed core Issues and Pull-Requests specific to Vault Core auth/cert Authentication - certificates labels Sep 21, 2021
stevendpclark added a commit that referenced this issue Oct 4, 2021
…me within pki certificates (#12336) (#12716)

* Fix 1.8 regression preventing email addresses being used as common name within pki certs (#12336)

* Add changelog
@stevendpclark
Copy link
Contributor

Closing issue as it was fixed within #12716

The fix will be available within the next major release and will be backported to Vault 1.8

stevendpclark added a commit that referenced this issue Oct 4, 2021
…me within pki certificates (#12336) (#12716)

* Fix 1.8 regression preventing email addresses being used as common name within pki certs (#12336)

* Add changelog
stevendpclark added a commit that referenced this issue Oct 5, 2021
…me within pki certificates (#12336) (#12716) (#12723)

* Fix 1.8 regression preventing email addresses being used as common name within pki certs (#12336)

* Add changelog
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Used to indicate a potential bug cryptosec secret/pki
Projects
None yet
Development

No branches or pull requests

4 participants