GPM: 生成式密码管理器

社区文章 发布于 2024年7月7日
Colab logo GitHub logo Neural tokenization

密码管理与 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)
    • 移动应用

社区

注册登录 评论