GPM: 生成式密码管理器
![]() ![]() |
![]() |
密码管理与 Cookie 弹窗和广告一样,都是一大痛点。
事实证明,你不需要**保存**密码,它们都可以从一个主密钥**派生**出来。
这是一个使用神经网络的小实验。
直觉
密码管理器可以看作是一个确定性函数:给定一个目标(应用程序、网站等)和登录 ID(用户名、邮件等),它们会生成一个密码。
password = manager(target, login)
通常它们只是保存你的凭据,以确保你总是获得相同的密码。
尽管表面上看,神经网络实际上也是确定性的。所以上面的 `manager` 函数可以是一个 (L)LM。
在这种情况下
- 主密钥将用于初始化权重
- MLP 将把登录和/或目标作为输入提示
- 密码将**不会被保存**,而是作为模型“预测”生成
此外,词汇表和模型可以设置为
- 满足密码要求
- 具有高熵同时保持确定性
- 创建像句子一样易于记忆的密码,而不是随机生成
并且 NN 不能是一个通用/共享模型,除非你乐意在登录字段中输入“password”。
0. 设置超参数
生成函数是 MLP:它由超参数定义。
- 随机数生成器的种子
- 张量形状
- 输入词汇表(所有 ASCII 字符)
- 输出词汇表(字母/数字/符号)
- 密码长度,即采样长度
0.1. 定义输入词汇表
输入被投影到 ASCII 表上,所有 Unicode 字符都被忽略。
无论用户输入了什么,此词汇表都是固定的
INPUT_VOCABULARY = ''.join(chr(__i) for __i in range(128)) # all ASCII characters
0.2. 构建输出词汇表
输出词汇表决定了模型输出的组成,即密码。
此词汇表可以包含
- 小写字母
- 大写字母
- 数字
- ASCII 符号,除了引号 `"` 和 `'`
VOCABULARY_ALPHA_UPPER = ''.join(chr(__i) for __i in range(65, 91)) # A-Z
VOCABULARY_ALPHA_LOWER = VOCABULARY_ALPHA_UPPER.lower() # a-z
VOCABULARY_NUMBERS = '0123456789' # 0-9
VOCABULARY_SYMBOLS = ''.join(chr(__i) for __i in range(33, 48) if chr(__i) not in ["'", '"']) # !#$%&\()*+,-./
它是根据用户偏好生成的,通过
def compose(lower: bool=True, upper: bool=True, digits: bool=True, symbols: bool=False) -> str:
return sorted(set(lower * VOCABULARY_ALPHA_LOWER + upper * VOCABULARY_ALPHA_UPPER + digits * VOCABULARY_NUMBERS + symbols * VOCABULARY_SYMBOLS))
默认情况下,它是
OUTPUT_VOCABULARY = ''.join(compose(1, 1, 1, 0))
# 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
最终,元参数是
N_INPUT_DIM = len(INPUT_VOCABULARY) # all ASCII characters
N_OUTPUT_DIM = N_INPUT_DIM # placeholder, it depends on the user settings
N_CONTEXT_DIM = 8
N_EMBEDDING_DIM = 128
N_PASSWORD_DIM = 16
N_PASSWORD_NONCE = 1
0.3. 将主密钥转换为种子
一种简单的方法是将主密钥解释为十六进制序列,然后转换为整数种子
def seed(key: str) -> int:
return int(bytes(key, 'utf-8').hex(), 16) % (2 ** 32) # dword
但是许多输入会生成相同的种子
print(seed('never seen before combination of letters'))
# 1952805491
print(seed('combination of letters'))
# 1952805491
print(b'combination of letters'.hex())
# 636f6d62696e6174696f6e206f66206c657474657273
字符串“字母组合”的编码需要 22 字节,因此它大于 `2 ** 168`。前缀意味着添加一个数字乘以 `2 ** 176`,这导致了与 `2 ** 32` 模数相同的值。
为了分离类似主密钥的编码,首先使用 sha256 进行哈希处理
def seed(key: str) -> int:
__hash = hashlib.sha256(string=key.encode('utf-8')).hexdigest()
return int(__hash[:8], 16) # take the first 4 bytes: the seed is lower than 2 ** 32
现在
print(seed('never seen before combination of letters'))
# 3588870616
print(seed('combination of letters'))
# 3269272188
1. 预处理输入
输入是用户想要密码的登录信息
- 登录目标
- 登录 ID
在交给模型处理之前,它们需要进行预处理,以确保输出符合用户预期。
1.1. 移除不需要的字符
首先,应该清理输入以
- 移除空格:它们没有任何作用,并且是像 `http://example. com` 这样的拼写错误
- 移除 unicode 字符:许多拼写错误会产生不可见的控制字符,例如 `chr(2002)`
空格可以使用以下方法移除
def remove_spaces(text: str) -> str:
return text.replace(' ', '').replace('\t', '')
1.2. 规范化字符串
可以使用几种变体来指向同一服务
example.com
https://example.com
https://example.com/
ExamPLE.COM
所以它们需要通过以下方式进行规范化
def remove_prefix(text: str) -> str:
__r = r'^((?:ftp|https?):\/\/)'
return re.sub(pattern=__r, repl='', string=text, flags=re.IGNORECASE)
def remove_suffix(text: str) -> str:
__r = r'(\/+)$'
return re.sub(pattern=__r, repl='', string=text, flags=re.IGNORECASE)
拼凑在一起
def preprocess(target: str, login: str) -> list:
__left = remove_suffix(text=remove_prefix(text=remove_spaces(text=target.lower())))
__right = remove_spaces(text=login.lower())
return __left + '|' + __right
print(preprocess(target='example.com', login='user'))
# example.com|user
print(preprocess(target='https://example.com', login='user'))
# example.com|user
print(preprocess(target='example.com/', login='USER'))
# example.com|user
2. 编码输入
2.1. 将字符映射到整数
字符和整数之间的映射是一个简单的枚举
def mappings(vocabulary: list) -> dict:
__itos = {__i: __c for __i, __c in enumerate(vocabulary)}
__stoi = {__c: __i for __i, __c in enumerate(vocabulary)}
# blank placeholder
__blank_c = __itos[0] # chr(0)
__blank_i = 0
# s => i
def __encode(c: str) -> int:
return __stoi.get(c, __blank_i)
# i => s
def __decode(i: int) -> str:
return __itos.get(i, __blank_c)
# return both
return {'encode': __encode, 'decode': __decode}
它将删除输入词汇表之外的所有字符,例如 Unicode 字符。
def encode(text: str, stoi: callable) -> list:
return [stoi(__c) for __c in text] # defaults to 0 if a character is not in the vocabulary
def decode(sequence: list, itos: callable) -> list:
return ''.join([itos(__i) for __i in sequence]) # defaults to the first character
2.2. 增加熵
使用字符级嵌入,输入张量将如下所示
array([101, 120, 97, 109, 112, 108, 101, 46, 99, 111, 109, 124, 117, 115, 101, 114], dtype=int32)
这意味着**输入中的每个重复都会在输出密码中产生一个重复**。
就像普通的 Transformer 模型一样,使用上下文作为输入会使每个样本更加独特。现在,一个样本由最新的 N 个字符组成
array([[ 0, 0, 0, 0, 0, 0, 0, 0],
[ 0, 0, 0, 0, 0, 0, 0, 101],
[ 0, 0, 0, 0, 0, 0, 101, 120],
[ 0, 0, 0, 0, 0, 101, 120, 97],
[ 0, 0, 0, 0, 101, 120, 97, 109],
[ 0, 0, 0, 101, 120, 97, 109, 112],
[ 0, 0, 101, 120, 97, 109, 112, 108],
[ 0, 101, 120, 97, 109, 112, 108, 101],
[101, 120, 97, 109, 112, 108, 101, 46],
[120, 97, 109, 112, 108, 101, 46, 99],
[ 97, 109, 112, 108, 101, 46, 99, 111],
[109, 112, 108, 101, 46, 99, 111, 109],
[112, 108, 101, 46, 99, 111, 109, 124],
[108, 101, 46, 99, 111, 109, 124, 117],
[101, 46, 99, 111, 109, 124, 117, 115],
[ 46, 99, 111, 109, 124, 117, 115, 101]], dtype=int32)
这仍然可以改进。只要过程是确定性的,输入就可以以任何方式修改。
例如,连续的序数值可以累积
def accumulate(x: int, y: int, n: int) -> int:
return (x + y) % n
模数确保编码保持在 ASCII 编码范围内。
此外,上下文可以从当前索引开始,而不是在当前索引结束。最后,编码后的输入可以循环使用以创建无限迭代器
def feed(source: list, nonce: int, dimension: int) -> iter:
__func = lambda __x, __y: accumulate(x=__x, y=__y + nonce, n=dimension) # add entropy by accumulating the encodings
return itertools.accumulate(iterable=itertools.cycle(source), func=__func) # infinite iterable
这将允许创建比输入文本更长的密码。
2.3. 格式化为张量
最后,编码输入的迭代器用于生成张量 X
def tensor(feed: 'Iterable[int]', length: int, context: int) -> tf.Tensor:
__x = [[next(feed) for _ in range(context)] for _ in range(length)]
return tf.constant(tf.convert_to_tensor(value=__x, dtype=tf.dtypes.int32))
此张量的形状为 `(N_PASSWORD_LENGTH, N_CONTEXT_DIM)`
__feed = feed(source=list('kaggle.com|apehex'.encode('utf-8')), nonce=1, dimension=256)
tensor(feed=__feed, length=N_PASSWORD_DIM, context=N_CONTEXT_DIM)
# <tf.Tensor: shape=(16, 8), dtype=int32, numpy=
# array([[107, 205, 53, 157, 10, 112, 159, 3],
# [115, 225, 94, 192, 49, 151, 0, 102],
# [223, 75, 173, 21, 125, 234, 80, 127],
# [227, 83, 193, 62, 160, 17, 119, 224],
# [ 70, 191, 43, 141, 245, 93, 202, 48],
# [ 95, 195, 51, 161, 30, 128, 241, 87],
# [192, 38, 159, 11, 109, 213, 61, 170],
# [ 16, 63, 163, 19, 129, 254, 96, 209],
# [ 55, 160, 6, 127, 235, 77, 181, 29],
# [138, 240, 31, 131, 243, 97, 222, 64],
# [177, 23, 128, 230, 95, 203, 45, 149],
# [253, 106, 208, 255, 99, 211, 65, 190],
# [ 32, 145, 247, 96, 198, 63, 171, 13],
# [117, 221, 74, 176, 223, 67, 179, 33],
# [158, 0, 113, 215, 64, 166, 31, 139],
# [237, 85, 189, 42, 144, 191, 35, 147]], dtype=int32)>
尽管输入字符串“kaggle.com|apehex”有重复(“e”和“a”),但张量中没有两行是相同的。
此处详述的过程将始终生成相同的张量 X。
3. 创建 MLP 模型
现在所有超参数都已设置,创建 MLP 只是形式上的。
def create_model(
seed: int,
n_input_dim: int,
n_output_dim: int,
n_context_dim: int=N_CONTEXT_DIM,
n_embedding_dim: int=N_EMBEDDING_DIM,
) -> tf.keras.Model:
__model = tf.keras.Sequential()
# initialize the weights
__embedding_init = tf.keras.initializers.GlorotNormal(seed=seed)
__dense_init = tf.keras.initializers.GlorotNormal(seed=(seed ** 2) % (2 ** 32)) # different values
# embedding
__model.add(tf.keras.layers.Embedding(input_dim=n_input_dim, output_dim=n_embedding_dim, embeddings_initializer=__embedding_init, name='embedding'))
# head
__model.add(tf.keras.layers.Reshape(target_shape=(n_context_dim * n_embedding_dim,), input_shape=(n_context_dim, n_embedding_dim), name='reshape'))
__model.add(tf.keras.layers.Dense(units=n_output_dim, activation='tanh', use_bias=False, kernel_initializer=__dense_init, name='head'))
__model.add(tf.keras.layers.Softmax(axis=-1, name='softmax'))
# compile
__model.compile(
optimizer=tf.keras.optimizers.Adam(learning_rate=0.0001),
loss=tf.keras.losses.CategoricalCrossentropy(from_logits=False, label_smoothing=0., axis=-1, reduction=tf.keras.losses.Reduction.SUM_OVER_BATCH_SIZE, name='loss'))
return __model
出于本概念验证的目的,我们使用 Tensorflow 和 Keras,但实际上也可以使用基本的矩阵乘法完成。
Numpy 也几乎一样方便使用,并且会产生相同的结果。
4. 采样 = 密码生成
张量 X 在上述模型中的前向传播将产生输出词汇表中每个字符的概率。
这可以直接解码为字符串,如下所示
def password(model: tf.keras.Model, x: tf.Tensor, itos: callable) -> str:
__y = tf.squeeze(model(x, training=False))
__p = list(tf.argmax(__y, axis=-1).numpy())
return decode(__p, itos=itos)
评估
所有操作都在 `process` 函数中整合在一起。
def process(
master_key: str,
login_target: str,
login_id: str,
password_length: int,
password_nonce: int,
include_lower: bool,
include_upper: bool,
include_digits: bool,
include_symbols: bool,
input_vocabulary: str=INPUT_VOCABULARY,
model_context_dim: int=N_CONTEXT_DIM,
model_embedding_dim: int=N_EMBEDDING_DIM
) -> str:
# seed to generate the model weights randomly
__seed = seed(key=master_key)
# input vocabulary
__input_mappings = mappings(vocabulary=input_vocabulary)
__input_dim = len(input_vocabulary)
# output vocabulary
__output_vocabulary = compose(lower=include_lower, upper=include_upper, digits=include_digits, symbols=include_symbols)
__output_mappings = mappings(vocabulary=__output_vocabulary)
__output_dim = len(__output_vocabulary)
# inputs
__inputs = preprocess(target=login_target, login=login_id)
__source = encode(text=__inputs, stoi=__input_mappings['encode'])
__feed = feed(source=__source, nonce=password_nonce, dimension=__input_dim)
__x = tensor(feed=__feed, length=password_length, context=model_context_dim)
# model
__model = create_model(seed=__seed, n_input_dim=__input_dim, n_output_dim=__output_dim, n_context_dim=model_context_dim, n_embedding_dim=model_embedding_dim)
# password
return password(model=__model, x=__x, itos=__output_mappings['decode'])
我们可以这样固定模型的内部参数
_process = functools.partial(
process,
password_length=32,
password_nonce=1,
include_lower=True,
include_upper=True,
include_digits=True,
include_symbols=False,
input_vocabulary=INPUT_VOCABULARY,
model_context_dim=N_CONTEXT_DIM,
model_embedding_dim=N_EMBEDDING_DIM)
这使得密码生成更容易测试。
print(_process(master_key='test', login_target='huggingface.co', login_id='apehex'))
# UEkmcY3IgIjT7o0ISs7qNon66FIVT1Qi
print(_process(master_key='test', login_target='https://huggingface.co/', login_id='APEHEX'))
# UEkmcY3IgIjT7o0ISs7qNon66FIVT1Qi
print(_process(master_key='anotherkey', login_target='https://huggingface.co/', login_id='APEHEX'))
# 4IazpOJxK4aOrBfnLsWNLrsq7ftzxHfE
正如所料,整个过程是确定性的:等效输入的调用将始终生成相同的密码,无需保存。
print(_process(master_key='verysecretpassphrase', login_target='example.com', login_id='u s e r@EMAIL.COM'))
# 4ZUHYALvuXvcSoS1p9j7R64freclXKvf
print(_process(master_key='verysecretpassphrase', login_target='HTTPS://example.com/', login_id='user@email.com'))
# 4ZUHYALvuXvcSoS1p9j7R64freclXKvf
CLI
我将此脚本封装在 Python CLI 中。
python gpm/main.py --key 'never seen before combination of letters' --target 'http://example.com' --id 'user@e.mail'
# YRLabEDKqWQrN6JF
参数的完整列表如下:
Generate / retrieve the password matching the input information
optional arguments:
-h, --help show this help message and exit
--key MASTER_KEY, -k MASTER_KEY the master key (all ASCII)
--target LOGIN_TARGET, -t LOGIN_TARGET the login target (URL, IP, name, etc)
--id LOGIN_ID, -i LOGIN_ID the login id (username, email, etc)
--length PASSWORD_LENGTH, -l PASSWORD_LENGTH the length of the password (default 16)
--nonce PASSWORD_NONCE, -n PASSWORD_NONCE the nonce of the password
--lower, -a exclude lowercase letters from the password
--upper, -A exclude uppercase letters from the password
--digits, -d exclude digits from the password
--symbols, -s include symbols in the password
改进
此概念验证可以发展成为一个成熟的产品,具有
- 性能改进
- 使用基础 `numpy` 而不是 `tensorflow`
- 用其基础权重张量和矩阵乘法替换模型
- 更多输出选项
- 将密码生成为词袋
- 创建完整的句子/语录
- 强制使用某些字符/子词汇表(如符号)
- 实际分发为
- 浏览器扩展
- 可执行二进制文件 (CLI)
- 移动应用