blob: 3c420035978b02b866f4a8d058fa1bc28785952d [file]
#!@pythonbin@
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You 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.
#
# dbmmanage-ng -- Python port of the historical Perl dbmmanage.
#
# This is a behavior-compatible reimplementation of support/dbmmanage.in
# in Python 3. It manages user/password DBM files for Apache auth.
#
# usage: dbmmanage <DBMfile> <command> <user> <password> <groups> <comment>
import os
import re
import sys
import base64
import hashlib
import getpass
import random
import dbm
# Python's crypt module is deprecated in 3.11+ and removed in 3.13. The
# Perl original uses crypt(), so we use it too -- but degrade gracefully
# if it is unavailable on this interpreter.
try:
import crypt as _crypt
except ImportError:
_crypt = None
# The set of characters used to build salts: [./0-9A-Za-z], matching the
# Perl genseed()/randchar() range.
_SALT_CHARS = "./0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
# Default hashing method, like the Perl original (crypt on Unix).
hash_method = "crypt"
def usage():
# Commands are listed sorted, like "join '|', sort keys %dbmc::".
cmds = "|".join(sorted(COMMANDS.keys()))
sys.stderr.write(
"Usage: dbmmanage [enc] dbname command "
"[username [pw [group[,group] [comment]]]]\n"
"\n"
" where enc is -d for crypt hashing (default except on Win32, Netware)\n"
" -m for MD5 hashing (default on Win32, Netware)\n"
" -s for SHA1 hashing\n"
" -p for plaintext\n"
"\n"
" command is one of: " + cmds + "\n"
"\n"
" pw of . for update command retains the old password\n"
" pw of - (or blank) for update command prompts for the password\n"
"\n"
" groups or comment of . (or blank) for update command retains old values\n"
" groups or comment of - for update command clears the existing value\n"
" groups or comment of - for add and adduser commands is the empty value\n"
)
sys.exit(1)
def die(msg):
sys.stderr.write(msg)
sys.exit(1)
def randchar(n=1):
return "".join(random.choice(_SALT_CHARS) for _ in range(n))
def saltpw_crypt():
# The Perl original optionally uses a "newstyle" salt on bsdos; that is
# an obscure edge case, so we always use the traditional 2-char salt.
return randchar(2)
def hashpw_crypt(pw, salt=None):
if _crypt is None:
die("dbmmanage: crypt hashing is not available on this Python "
"interpreter.\nPlease use a different hashing option (-m, -s, -p).\n")
if not salt:
salt = saltpw_crypt()
return _crypt.crypt(pw, salt)
def saltpw_md5():
return randchar(8)
# Apache's apr1 / md5crypt algorithm. Python's stdlib has no apr1
# implementation, so we implement it here. This is the classic md5crypt
# routine with the magic string "$apr1$". Given the same (pw, salt) it
# produces the identical $apr1$<salt>$<hash> string as htpasswd / Apache.
def apache_md5_crypt(pw, salt):
magic = "$apr1$"
pw_b = pw.encode("utf-8", "surrogateescape")
# If a full $apr1$salt$ string was passed in (e.g. when verifying),
# extract just the salt portion.
if salt.startswith(magic):
salt = salt[len(magic):]
salt = salt.split("$", 1)[0]
salt = salt[:8]
salt_b = salt.encode("ascii")
# Primary digest: password + magic + salt.
ctx = hashlib.md5(pw_b + magic.encode("ascii") + salt_b)
# Alternate digest: password + salt + password.
alt = hashlib.md5(pw_b + salt_b + pw_b).digest()
# Add as many chars of the alternate digest as the password length.
pw_len = len(pw_b)
i = pw_len
while i > 0:
ctx.update(alt[:16] if i > 16 else alt[:i])
i -= 16
# For each bit of the password length, add either a NUL byte or the
# first byte of the password.
i = pw_len
while i:
if i & 1:
ctx.update(b"\x00")
else:
ctx.update(pw_b[:1])
i >>= 1
final = ctx.digest()
# 1000 iterations of strengthening.
for i in range(1000):
ctx = hashlib.md5()
if i & 1:
ctx.update(pw_b)
else:
ctx.update(final)
if i % 3:
ctx.update(salt_b)
if i % 7:
ctx.update(pw_b)
if i & 1:
ctx.update(final)
else:
ctx.update(pw_b)
final = ctx.digest()
# Custom base64 encoding (md5crypt order/alphabet: ./0-9A-Za-z).
itoa64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
def _to64(v, n):
out = []
for _ in range(n):
out.append(itoa64[v & 0x3f])
v >>= 6
return "".join(out)
out = ""
out += _to64((final[0] << 16) | (final[6] << 8) | final[12], 4)
out += _to64((final[1] << 16) | (final[7] << 8) | final[13], 4)
out += _to64((final[2] << 16) | (final[8] << 8) | final[14], 4)
out += _to64((final[3] << 16) | (final[9] << 8) | final[15], 4)
out += _to64((final[4] << 16) | (final[10] << 8) | final[5], 4)
out += _to64(final[11], 2)
return magic + salt + "$" + out
def hashpw_md5(pw, salt=None):
if not salt:
salt = saltpw_md5()
return apache_md5_crypt(pw, salt)
def hashpw_sha1(pw, salt=None):
# '{SHA}' + base64(sha1(pw)). The Perl original used unpadded base64
# from Digest::SHA1 then appended a literal "="; the de-facto correct
# Apache htpasswd {SHA} format is the standard *padded* base64 (28
# chars ending in "=" for a 20-byte digest), which base64.b64encode
# produces directly.
return "{SHA}" + base64.b64encode(
hashlib.sha1(pw.encode("utf-8", "surrogateescape")).digest()
).decode("ascii")
def hashpw(pw, salt=None):
if hash_method == "md5":
return hashpw_md5(pw, salt)
elif hash_method == "sha1":
return hashpw_sha1(pw, salt)
elif hash_method == "crypt":
return hashpw_crypt(pw, salt)
return pw # otherwise return plaintext
def prompt_pass(prompt="Enter password:"):
pwd = getpass.getpass(prompt)
if not len(pwd):
die("Can't use empty password!\n")
return pwd
# ---------------------------------------------------------------------------
# DBM access helpers. dbm stores bytes; we use latin-1 consistently so that
# arbitrary hashed-password bytes round-trip cleanly.
# ---------------------------------------------------------------------------
_ENC = "latin-1"
def db_has(db, key):
return key.encode(_ENC) in db
def db_get(db, key):
return db[key.encode(_ENC)].decode(_ENC)
def db_set(db, key, value):
db[key.encode(_ENC)] = value.encode(_ENC)
def db_del(db, key):
del db[key.encode(_ENC)]
def db_keys(db):
return [k.decode(_ENC) for k in db.keys()]
# ---------------------------------------------------------------------------
# Commands (the Perl dbmc:: subs). These operate on the module-level state
# (db, key, hashed_pwd, groups, comment, is_update).
# ---------------------------------------------------------------------------
class State:
db = None
key = None
hashed_pwd = None
groups = None
comment = None
is_update = False
S = State()
def cmd_add():
if not S.hashed_pwd:
die("Can't use empty password!\n")
if not S.is_update:
if db_has(S.db, S.key):
die("Sorry, user `%s' already exists!\n" % S.key)
groups = S.groups or ""
comment = S.comment or ""
if groups == "-":
groups = ""
if comment == "-":
comment = ""
if comment:
groups = groups + ":" + comment
value = S.hashed_pwd
if groups:
value = value + ":" + groups
db_set(S.db, S.key, value)
action = "updated" if S.is_update else "added"
print("User %s %s with password hashed to %s using %s"
% (S.key, action, value, hash_method))
def cmd_adduser():
value = prompt_pass("New password:")
if prompt_pass("Re-type new password:") != value:
die("They don't match, sorry.\n")
S.hashed_pwd = hashpw(value)
cmd_add()
def cmd_update():
if not db_has(S.db, S.key):
die("Sorry, user `%s' doesn't exist!\n" % S.key)
parts = (db_get(S.db, S.key).split(":", 2) + ["", "", ""])[:3]
if S.hashed_pwd == ".":
S.hashed_pwd = parts[0]
if not S.groups or S.groups == ".":
S.groups = parts[1]
if not S.comment or S.comment == ".":
S.comment = parts[2]
if not S.hashed_pwd or S.hashed_pwd == "-":
cmd_adduser()
else:
cmd_add()
def cmd_delete():
if not db_has(S.db, S.key):
die("Sorry, user `%s' doesn't exist!\n" % S.key)
db_del(S.db, S.key)
print("`%s' deleted" % S.key)
def cmd_view():
if S.key:
# Like Perl's $DB{$key}, a missing key yields the empty value.
val = db_get(S.db, S.key) if db_has(S.db, S.key) else ""
print("%s:%s" % (S.key, val))
else:
for k in db_keys(S.db):
v = db_get(S.db, k)
if v:
print("%s:%s" % (k, v))
def cmd_check():
global hash_method
if not db_has(S.db, S.key):
die("Sorry, user `%s' doesn't exist!\n" % S.key)
chkpass = (db_get(S.db, S.key).split(":", 2) + ["", "", ""])[0]
testpass = prompt_pass()
if chkpass[:6] == "$apr1$":
hash_method = "md5"
elif chkpass[:5] == "{SHA}":
hash_method = "sha1"
elif len(chkpass) == 13 and chkpass != testpass:
hash_method = "crypt"
else:
hash_method = "plain"
ok = hashpw(testpass, chkpass) == chkpass
print(hash_method + (" password ok" if ok else " password mismatch"))
def cmd_import():
for line in sys.stdin:
line = line.rstrip("\n").rstrip("\r")
if not line:
continue
fields = (line.split(":", 3) + ["", "", "", ""])[:4]
S.key, S.hashed_pwd, S.groups, S.comment = fields
cmd_add()
COMMANDS = {
"add": cmd_add,
"adduser": cmd_adduser,
"check": cmd_check,
"delete": cmd_delete,
"import": cmd_import,
"update": cmd_update,
"view": cmd_view,
}
def main():
global hash_method
argv = sys.argv[1:]
# Consume the enc flag from the front of argv, if present.
if argv and argv[0] == "-d":
argv.pop(0)
hash_method = "crypt"
elif argv and argv[0] == "-m":
argv.pop(0)
hash_method = "md5"
elif argv and argv[0] == "-p":
argv.pop(0)
hash_method = "plain"
elif argv and argv[0] == "-s":
argv.pop(0)
hash_method = "sha1"
file = argv[0] if len(argv) > 0 else None
command = argv[1] if len(argv) > 1 else None
S.key = argv[2] if len(argv) > 2 else None
S.hashed_pwd = argv[3] if len(argv) > 3 else None
S.groups = argv[4] if len(argv) > 4 else None
S.comment = argv[5] if len(argv) > 5 else None
if not file or not command or command not in COMMANDS:
usage()
# remove extension if any: .db, .db?, .pag, .dir
file = re.sub(r"\.(db.?|pag|dir)$", "", file)
S.is_update = command == "update"
# view/check open read-only; everything else read-write (create).
if command in ("view", "check"):
flag = "r"
else:
flag = "c"
try:
S.db = dbm.open(file, flag)
except Exception as e:
die("Can't open %s: %s\n" % (file, e))
try:
COMMANDS[command]()
finally:
S.db.close()
if __name__ == "__main__":
main()