Keystone Fernet Token解析

Token的一点历史

Keystone在早期的版本中,其认证Token有好几种类型,最早期的也是当时最成熟的是UUID类型的Token,它将token以及其元数据存储在数据库中,以一个UUID来标识一个Token,这种方式一个明显的问题就是当Token量很大时,会往数据库中写大量的Token,一个中等集群,往往数据库中有十几G的数据都是Token,需要定期清理,否则会拖慢其他请求;后来发展出了基于公私钥非对称加密的PKI/PKIZ Token,这种Token不需要存数据库,Token元数据直接被编码到Token ID中,而且因为是非对称的,甚至都不需要跟Keystone交互,本地就可以完成验证以及解析Token,但是这种Token,因为Token元数据都在Token ID中,随着集群大小以及复杂度的变化,其长度也会变化,有时会变得非常长,超过了HTTP对Header的长度限制,再加上公私钥的管理也比较复杂,所以PKI体系的Token基本没有被用起来;于是就有了Fernet Token,它可以看成是UUID和PKI折中的一种方案,它有以下几个特点:

  1. Token不存数据库
  2. Token长度不会变得很长,一般小于255个字符
  3. 采用AES-CBC对称加密算法加密Token,并且使用散列函数SHA256进行签名
  4. Token验证需要到Keystone Server进行,不能本地验证
  5. 为了安全,进行对称加密的key,需要定期Rotate

相比UUID,它不存数据库,减少了对数据库的压力,而且使得性能有了一定的提升;相比PKI,它长度固定,而且对称加密,比较好管理,因此随着Fernet逐渐成熟,发展到Rocky版本,UUID和PKI/PKIZ这两种Token类型已经被移除掉了,Fernet现在是唯一的Token类型。

Play around with Fernet Token

先来简单看看Fernet Token长啥样,以及怎么验证一个Token:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[root@control1 ~]# . admin-openrc
[root@control1 ~]# openstack token issue
+------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Field | Value |
+------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| expires | 2020-11-10T15:04:48+0000 |
| id | gAAAAABfqVqQlgiZgky_i2mWVbknmmGRbHSZ9RGrkqPp2GNuhd_n5D7RB5uD6ngaWzr-zCdIKxDXVk9mgzDxTS7QhRH1mUpnEbMj7JPpHNU3vDXI4Zm5mgPVZqxAQOWLhmBRj_ELcnzY_RtikwoyaLk41ogvMFA6NUu7fh0eeWuipVYgsL8w9YY |
| project_id | 3c638b2eb36b4da6944040bb31084421 |
| user_id | c9c34b222cae43ef9b721ece47545431 |
+------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
[root@control1 ~]# . guangyu-openrc
[root@control1 ~]# openstack token issue
+------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Field | Value |
+------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| expires | 2020-11-10T15:04:58+0000 |
| id | gAAAAABfqVqa85e6hC6SJ8dWM0tE0C0Ast-_NnmInVTZTM8n_XLpkBGiuoAGBIejJW3oyixZoJc4g82ezpPh_WGRBW47SkcFOsVmItAhw_GOrWofTzjPM3Oekt5Fk6bBpa8tVsT-qec8DTW6tEq2Wm2Yc4Jmw3nkX6mbMdNMR-zxeGfq8B5MJcA |
| project_id | e9cdf316e25d433bb69278be3339ded0 |
| user_id | fee9dca90b2e46dc8f31960c517a3baf |
+------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

这里,source了两个openrc文件,一个是管理员admin的,一个是普通用户guangyu的,然后分别用openstack token issue命令创建了这两个用户的token,其中id那一行就是Fernet Token,可以看到它要比一个UUID长很多,但是也不是特别长,还可以接受。接下来我们使用admin的token调用Keystone的API来验证一下guangyu的Token是否有效,这就好比一个请求带着token去请求Nova API,Nova API本身不具备验证这个Token是否有效的能力,所以它需要向Keystone去验证这个Token是否有效,验证Token的API为 GET /v3/auth/tokens:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
[root@control1 ~]# ADMIN_TOKEN="gAAAAABfqVqQlgiZgky_i2mWVbknmmGRbHSZ9RGrkqPp2GNuhd_n5D7RB5uD6ngaWzr-zCdIKxDXVk9mgzDxTS7QhRH1mUpnEbMj7JPpHNU3vDXI4Zm5mgPVZqxAQOWLhmBRj_ELcnzY_RtikwoyaLk41ogvMFA6NUu7fh0eeWuipVYgsL8w9YY"
[root@control1 ~]# GUANGYU_TOKEN="gAAAAABfqVqa85e6hC6SJ8dWM0tE0C0Ast-_NnmInVTZTM8n_XLpkBGiuoAGBIejJW3oyixZoJc4g82ezpPh_WGRBW47SkcFOsVmItAhw_GOrWofTzjPM3Oekt5Fk6bBpa8tVsT-qec8DTW6tEq2Wm2Yc4Jmw3nkX6mbMdNMR-zxeGfq8B5MJcA"
[root@control1 ~]# curl -H "X-Auth-Token: $ADMIN_TOKEN" -H "X-Subject-Token: $GUANGYU_TOKEN" http://10.110.105.30:35357/v3/auth/tokens | jq .

{
"token": {
"is_domain": false,
"methods": [
"password"
],
"roles": [
{
"id": "26796d7d1f8447a3ab95d0d31c3bca37",
"name": "reader"
},
{
"id": "ea022f3532ad4f6cafbc63f9a1bce8f3",
"name": "creator"
},
{
"id": "470a11fdfb7a49b48c1a5d9524a98cf9",
"name": "member"
}
],
"expires_at": "2020-11-10T15:04:58.000000Z",
"project": {
"domain": {
"id": "default",
"name": "Default"
},
"id": "e9cdf316e25d433bb69278be3339ded0",
"name": "guangyu_project"
},
"catalog": [
{
"endpoints": [
{
"url": "http://10.110.105.30:9292",
"interface": "admin",
"region": "RegionOne",
"region_id": "RegionOne",
"id": "783d732505fb4ce58fb677b1b974f424"
},
......
}
],
"user": {
"password_expires_at": null,
"domain": {
"id": "default",
"name": "Default"
},
"id": "fee9dca90b2e46dc8f31960c517a3baf",
"name": "guangyu"
},
"audit_ids": [
"AnPMxLBlQjOZTHrd0ttwlA"
],
"issued_at": "2020-11-09T15:04:58.000000Z"
}
}

可以看到该接口传入的两个Header,X-Auth-Token为调用API要传入的Token,这个一定是有效的合法的Token,否则会报401,而X-Subject-Token则是需要验证的Token,这个Token可能是无效的,也可能是有效的,需要在服务端进行验证才行,如果验证有效,则会将该Token所代表的详细信息返回,可以看到包含role, project, domain, catalog, user等信息,如果觉得catalog太长不想要,则可以传一个?nocatalog=true参数,返回值中就不会包含catalog了。

创建Token

下面我们来看看Token是怎么创建出来的,为什么说其长度基本固定,以及它怎么做的对称加密。其关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71

# keystone/token/token_formatters.py

def create_token(self, user_id, expires_at, audit_ids, methods=None,
system=None, domain_id=None, project_id=None,
trust_id=None, federated_group_ids=None,
identity_provider_id=None, protocol_id=None,
access_token_id=None, app_cred_id=None):
"""Given a set of payload attributes, generate a Fernet token."""

......

version = payload_class.version
payload = payload_class.assemble(
user_id, methods, system, project_id, domain_id, expires_at,
audit_ids, trust_id, federated_group_ids, identity_provider_id,
protocol_id, access_token_id, app_cred_id
)

versioned_payload = (version,) + payload
serialized_payload = msgpack.packb(versioned_payload)
token = self.pack(serialized_payload)

# NOTE(lbragstad): We should warn against Fernet tokens that are over
# 255 characters in length. This is mostly due to persisting the tokens
# in a backend store of some kind that might have a limit of 255
# characters. Even though Keystone isn't storing a Fernet token
# anywhere, we can't say it isn't being stored somewhere else with
# those kind of backend constraints.
if len(token) > 255:
LOG.info('Fernet token created with length of %d '
'characters, which exceeds 255 characters',
len(token))

return token

@property
def crypto(self):
"""Return a cryptography instance.

You can extend this class with a custom crypto @property to provide
your own token encoding / decoding. For example, using a different
cryptography library (e.g. ``python-keyczar``) or to meet arbitrary
security requirements.

This @property just needs to return an object that implements
``encrypt(plaintext)`` and ``decrypt(ciphertext)``.

"""
fernet_utils = utils.FernetUtils(
CONF.fernet_tokens.key_repository,
CONF.fernet_tokens.max_active_keys,
'fernet_tokens'
)
keys = fernet_utils.load_keys()

if not keys:
raise exception.KeysNotFound()

fernet_instances = [fernet.Fernet(key) for key in keys]
return fernet.MultiFernet(fernet_instances)

def pack(self, payload):
"""Pack a payload for transport as a token.

:type payload: six.binary_type
:rtype: six.text_type

"""
# base64 padding (if any) is not URL-safe
return self.crypto.encrypt(payload).rstrip(b'=').decode('utf-8')

可见,参与Token生成的一些元数据,大部分由一些id组成的payload,比如user_id, project_id, domain_id等等,还有一些诸如methods, expires_at等长度基本上变化很小的元数据,然后加载用于加密签名的key,对这些数据组成的payload进行加密,签名以及base64编码,最终得到了我们前面看到的Fernet Token。

验证Token

接下来,再来看下是如何验证Token的,这个token在不存数据库的情况下是如何获取其代表的信息的,其关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

# keystone/token/token_formatters.py

def validate_token(self, token):
"""Validate a Fernet token and returns the payload attributes.

:type token: six.text_type

"""
serialized_payload = self.unpack(token)
versioned_payload = msgpack.unpackb(serialized_payload)
version, payload = versioned_payload[0], versioned_payload[1:]

......

# rather than appearing in the payload, the creation time is encoded
# into the token format itself
issued_at = TokenFormatter.creation_time(token)
issued_at = ks_utils.isotime(at=issued_at, subsecond=True)
expires_at = timeutils.parse_isotime(expires_at)
expires_at = ks_utils.isotime(at=expires_at, subsecond=True)

return (user_id, methods, audit_ids, system, domain_id, project_id,
trust_id, federated_group_ids, identity_provider_id,
protocol_id, access_token_id, app_cred_id, issued_at,
expires_at)

def unpack(self, token):
"""Unpack a token, and validate the payload.

:type token: six.text_type
:rtype: six.binary_type

"""
token = TokenFormatter.restore_padding(token)

try:
return self.crypto.decrypt(token.encode('utf-8'))
except fernet.InvalidToken:
raise exception.ValidationError(
_('This is not a recognized Fernet token %s') % token)

可见,将token id传进来,使用key将其进行解密操作,最终还原回当初创建该token时的那些元数据,所以之所以Fernet Token可以不用存数据库,是因为这些信息本身就是保存在Token中的,经过服务端对该Token进行解密,就可以还原回来元数据信息,只是因为这些元数据大小基本不会发生变化,所以其长度可控。

Fernet Token Rotation

从上面的步骤中可以看出,Fernet Token是采用对称加密的方式进行加密,所以用于加密的key的安全性就非常重要,如果被中间人将这个key破解出来,或者这个key泄露出去,那整个系统的安全性就完全暴露出去了,所以为了保证key的安全,我们定期需要更换这个key,而为了保证key认证的连续性,即在用一个Key签发的最后一个token过期之前,还不能将这个key删除掉,因为还需要用它来解密,所以需要采取渐进式的删除策略,即这里所说的Rotation。

因此会有多个key同时共存在key repo中,由配置参数max_active_keys来决定,该参数默认为3,最小为1,key repo默认的路径为:/etc/keystone/fernet-keys/,key的文件名都是以数字表示,示例如下:

1
2
(keystone)[root@control1 /]$ ls /etc/keystone/fernet-keys/
0 107 108

每个key在其生命周期中都会经历3个阶段:

  • Primary Key,处在该阶段的key用来加密和解密token,编号最高的那个是Primary Key,如上例中的108;
  • Secondary Key,处在该阶段的key只用来解密token,它是由Primary Key退变而来的,即它曾经是Primary Key,经历过一次rotate后,Primary Key就变成了Secondary Key,除了最高和最低编号的,其他都是Secondary Key,如上例中的107;
  • Staged Key,该阶段的key,编号始终为0,每一次rotate,Staged Key被转变成了Primary Key,即编号由0变成了最高,然后再生成一个新的编号为0的key,所以顾名思义,Staged Key就是准备变成Primary的Key。

下面通过一个图,来看一下Token的Rotation的过程:

  1. 最开始只有0和1两个key,分别为staged 和 primary key;
  2. 经过一次rotate,编号为0的staged key,变成了编号为2的primary key,而编号为1的primary key变成了secondary key,但是编号没有变,然后新生成了一个编号为0的staged key,此时,共有0, 1, 2 三个key。
  3. 再经过一次rotate,编号为0的staged key,变成了编号为3的primary key,而编号为2的primary key变成了secondary key,但是编号没有变,原本编号为1的secondary key,则被删除掉了,然后新生成了一个编号为0的staged key,此时,共有0, 2, 3三个key;

所以从上面可以总结出规律,staged key总是编号为0,并且要变成下一个primary key,而primay key总是编号最大的那一个,它要变成下一个secondary key,而剩下的则是secondary key,是要被淘汰删除掉的key。

通过这种方式,渐进的将key进行更新,不会出现中断导致token没法被解析的情况。

作者

hackerain

发布于

2020-11-09

更新于

2023-03-11

许可协议