A deterministic password manager.
On Windows, Linux, and macOS (x64 and ARM64), you can use the pre-built binaries:
https://github.com/samuel-lucas6/Cahir/releases
If your system has the latest .NET 8 SDK, you can build from source (replace RID
with your platform):
$ cd src
$ dotnet publish -r RID -c Release
If you try to use your YubiKey on Linux, you'll likely get an error mentioning libudev.so
. To get things working, you must create a symbolic link from libudev.so.1
to the directory .NET is using for Cahir. On Debian-based distros, the command will look something like this:
$ sudo ln -s /usr/lib/x86_64-linux-gnu/libudev.so.1 /home/samuel/.net/cahir/ftlSfb7AS_XKCtW7BksiMKLSs1L2dYc=/libudev.so
USAGE:
cahir [OPTIONS]
EXAMPLES:
cahir -i "alicejones@pm.me" -d "github.com"
cahir -i "alicejones@pm.me" -d "github.com" -p "correct horse battery staple"
cahir -i "+44 07488 855302" -d "github.com" -f "password.txt"
cahir -i "+44 07488 855302" -d "github.com" -k "pepper.key"
OPTIONS:
-i, --identity <IDENTITY> Your unique identifier (e.g. email address)
-d, --domain <DOMAIN> The website domain (e.g. github.com)
-p, --password <PASSWORD> Your master password (omit for interactive entry)
-f, --password-file <FILE> Your master password stored as a file (omit for interactive entry)
-k, --keyfile <FILE> Your pepper stored as a file
-g, --generate <FILE> Randomly generate a keyfile with the specified file name
-y, --yubikey [SLOT] Use your YubiKey for challenge-response (defaults to slot 2)
-m, --modify-slot [SLOT] Set up a challenge-response YubiKey slot
-c, --counter <COUNTER> The counter for when a site password needs to be changed (default is 1)
-l, --length <LENGTH> The length of the derived site password (default is 20 characters or 8 words)
-a, --lowercase Include lowercase characters in the derived site password
-u, --uppercase Include uppercase characters in the derived site password
-n, --numbers Include numbers in the derived site password
-s, --symbols Include symbols in the derived site password
-w, --words Derive a passphrase
-v, --version Prints version information
-h, --help Prints help information
Cahir aims for security against an adversary who does not have physical or remote access to the user's machine. With such access, security cannot be guaranteed because the adversary has compromised the device. For example, they can use hardware/software keyloggers, memory forensics, disk forensics, and so on. However, Cahir attempts to zero sensitive data to minimise the risk of retrieval from memory or disk, and there is some protection against shoulder surfing.
To guess user inputs, the adversary must either perform:
For security, we assume that:
salt = BLAKE2b-256(context || identity)
masterKey = Argon2id(password, salt, memorySize, passes, parallelism)
context
: the UTF-8 encoding of "cahir.salt"
(10 bytes).identity
: the UTF-8 encoding of the -i, --identity
string (1+ bytes).password
: the UTF-8 encoding of the -p, --password
or interactively entered password string, which cannot be empty and is limited to 128 characters, or the bytes stored in the -f, --password-file
file (1-387 bytes).salt
: the salt derived above (32 bytes).memorySize
: 256 MiB.passes
: 3 passes.parallelism
: 1 lane.pepper = BLAKE2b-256(context || keyfile)
context
: the UTF-8 encoding of "cahir.keyfile"
(13 bytes).keyfile
: the bytes stored in the -k, --keyfile
file (1+ bytes).challenge = BLAKE2b-256(key: masterKey, message: context || counter || length || characterSet || domain)
masterKey
: the key from master key derivation (32 bytes).context
: the UTF-8 encoding of "cahir.challenge"
(15 bytes).counter
: the little-endian encoding of the unsigned 32-bit -c, --counter
integer (4 bytes).length
: the little-endian encoding of the unsigned 32-bit -l, --length
integer (4 bytes).characterSet
: a single 0x01
or 0x00
byte (representing true or false respectively) for -a, --lowercase
, -u, --uppercase
, -n, --numbers
, -s, --symbols
, and -w, --words
in that order (5 bytes).domain
: the UTF-8 encoding of the -d, --domain
string (1+ bytes).response = HMAC-SHA1(key: yubikeySecret, message: challenge || padding)
pepper = BLAKE2b-256(key: response, message: context)
yubikeySecret
: the secret key stored on the YubiKey in an OTP slot (20 bytes).challenge
: the challenge derived above (32 bytes).padding
: an all-zero buffer (32 bytes).response
: the HMAC-SHA1 output (20 bytes).context
: the UTF-8 encoding of "cahir.response"
(14 bytes).siteKey = BLAKE2b-256(key, message: context || counter || length || characterSet || domain)
key
: the masterKey
from master key derivation or masterKey || pepper
if a keyfile or YubiKey challenge-response is used (32 or 64 bytes).context
: the UTF-8 encoding of "cahir.sitekey"
(13 bytes).counter
: the little-endian encoding of the unsigned 32-bit -c, --counter
integer (4 bytes).length
: the little-endian encoding of the unsigned 32-bit -l, --length
integer (4 bytes).characterSet
: a single 0x01
or 0x00
byte (representing true or false respectively) for -a, --lowercase
, -u, --uppercase
, -n, --numbers
, -s, --symbols
, and -w, --words
in that order (5 bytes).domain
: the UTF-8 encoding of the -d, --domain
string (1+ bytes).ciphertext = ChaCha20(plaintext, nonce, key, counter)
if lowercase
characterSet += "abcdefghijklmnopqrstuvwxyz"
if uppercase
characterSet += "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
if numbers
characterSet += "0123456789"
if symbols
characterSet += "!#$%&'()*+,-./:;<=>?@[]^_`{}~"
for i = 0 to length
randomIndex = LE128(ciphertext.Slice(start: i * 16, length: 16)) % characterSet.Length
sitePassword[i] = characterSet[randomIndex]
plaintext
: an all-zero buffer (-l, --length
* 16 bytes).nonce
: the UTF-8 encoding of "cahir.sitepw"
(12 bytes).key
: the siteKey
from site key derivation (32 bytes).counter
: an unsigned 32-bit integer equal to 0.lowercase
: whether -a, --lowercase
was specified.uppercase
: whether -u, --uppercase
was specified.numbers
: whether -n, --numbers
was specified.symbols
: whether -s, --symbols
was specified.length
: the -l, --length
integer.randomIndex
: an unsigned 128-bit integer converted to a signed 32-bit integer.ciphertext = ChaCha20(plaintext, nonce, key, counter)
offset = 0
characterSet = "0123456789"
randomNumber = 0
randomPosition = 0
if numbers
offset = 2
randomNumber = LE128(ciphertext.Slice(start: 0, length: 16)) % characterSet.Length
randomPosition = LE128(ciphertext.Slice(start: 16, length: 16)) % wordCount
count = 0
wordlist = BIP39.Split("\n")
for i = 0 to wordCount
randomIndex = LE128(ciphertext.Slice(start: (i + offset) * 16, length: 16)) % wordlist.Length
word = wordlist[randomIndex]
for j = 0 to word.Length
if uppercase and j == 0
sitePassphrase[count] = word[j].ToUpper()
else
sitePassphrase[count] = word[j]
count++
if numbers and i == randomPosition
sitePassphrase[count] = characterSet[randomNumber]
count++
if i != wordCount - 1
if symbols
sitePassphrase[count] = "-"
else
sitePassphrase[count] = " "
count++
plaintext
: an all-zero buffer (-l, --length
16 bytes or (-l, --length
+ 2) 16 bytes if -n, --numbers
).nonce
: the UTF-8 encoding of "cahir.sitepp"
(12 bytes).key
: the siteKey
from site key derivation (32 bytes).counter
: an unsigned 32-bit integer equal to 0.randomNumber
: an unsigned 128-bit integer converted to a signed 32-bit integer.randomPosition
: an unsigned 128-bit integer converted to a signed 32-bit integer.wordCount
: the -l, --length
integer.wordlist
: a string array created by splitting the BIP39 English wordlist by newline.randomIndex
: an unsigned 128-bit integer converted to a signed 32-bit integer.uppercase
: whether -u, --uppercase
was specified.numbers
: whether -n, --numbers
was specified.symbols
: whether -s, --symbols
was specified.