├── .fossil-settings └── ignore-glob ├── HEADER ├── Makefile ├── README.md ├── LICENSE └── hunter2 /.fossil-settings/ignore-glob: -------------------------------------------------------------------------------- 1 | lib 2 | -------------------------------------------------------------------------------- /HEADER: -------------------------------------------------------------------------------- 1 | @@UTIL@@ @@VERS@@ 2 | 3 | Release information: 4 | pkg: @@UTIL@@ version @@VERS@@ 5 | url: http://www.rkeene.org/devel/@@UTIL@@-@@VERS@@.tar.gz 6 | date: @@DATE@@ 7 | -------------------------------------------------------------------------- 8 | 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PREFIX = /usr/local 2 | prefix = $(PREFIX) 3 | bindir = $(prefix)/bin 4 | libdir = $(prefix)/lib 5 | 6 | all: hunter2 7 | 8 | install: hunter2 lib 9 | mkdir -p '$(DESTDIR)$(bindir)' '$(DESTDIR)$(libdir)/hunter2' 10 | cp -rp lib/* '$(DESTDIR)$(libdir)/hunter2/' 11 | sed 's@\[file dirname \[info script\]\] lib@"$(libdir)/hunter2"@' hunter2 > '$(DESTDIR)$(bindir)/hunter2' 12 | chmod 755 '$(DESTDIR)$(bindir)/hunter2' 13 | 14 | clean: 15 | @echo 'All clean!' 16 | 17 | distclean: 18 | @echo 'All clean!' 19 | 20 | .PHONY: all install clean distclean 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | All I see are stars 2 | =================== 3 | 4 | About 5 | ----- 6 | The "hunter2" password manager is a simple script-oriented password 7 | manager. You request that passwords be stored by a given identifier and 8 | then later retrieve them with that identifier. 9 | 10 | Passwords are encrypted using your public RSA key and can be decrypted 11 | with your private RSA key. Currently only keys stored on hardware 12 | security modules (such as smartcards, TPMs, etc) are supported. 13 | 14 | Passwords may be shared among users of the same database and anyone who 15 | can decrypt the password may add additional users be able to access the 16 | password. 17 | 18 | Passwords are stored in a simple SQLite3 DB. AES-128 is used to encrypt 19 | the passwords and RSA is used to encrypt the AES key. 20 | 21 | Demo 22 | ---- 23 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Roy Keene 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 1. Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following 11 | disclaimer in the documentation and/or other materials provided 12 | with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 15 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 16 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 17 | PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 18 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 19 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 20 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 21 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 22 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 23 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /hunter2: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env tclsh 2 | 3 | # Copyright (c) 2016, Roy Keene 4 | # All rights reserved. 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 1. Redistributions of source code must retain the above copyright 10 | # notice, this list of conditions and the following disclaimer. 11 | # 12 | # 2. Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following 14 | # disclaimer in the documentation and/or other materials 15 | # provided with the distribution. 16 | # 17 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 18 | # IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 19 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 20 | # PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | # HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 23 | # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 24 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 25 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 26 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | set passwordFile [lindex $argv 0] 30 | set action [lindex $argv 1] 31 | 32 | set validCommands [list "listLocalKeys" "listPasswords" "listAvailablePasswords" "listUsers" "addUser" "addPassword" "authorizeUser" "authorizeUsers" "deauthorizeUser" "deauthorizeUsers" "getPassword" "updatePassword" "deletePassword" "help"] 33 | 34 | proc _argDescription {command argName} { 35 | switch -- $argName { 36 | "passwordName" { 37 | return "$argName - Name of the password entry" 38 | } 39 | "key" { 40 | return "$argName - Public key of the user" 41 | } 42 | "password" { 43 | return "$argName - A plain-text password" 44 | } 45 | "userName" { 46 | return "$argName - A user name" 47 | } 48 | "action" { 49 | return "$argName - An action name for help with" 50 | } 51 | "args" { 52 | return "userList - A list of usernames" 53 | } 54 | } 55 | 56 | return "" 57 | } 58 | 59 | proc _printHelp {channel command} { 60 | if {$command == ""} { 61 | puts $channel "Usage: hunter2 \[\]" 62 | puts $channel "" 63 | puts $channel "Actions:" 64 | puts $channel " [join $::validCommands {, }]" 65 | puts $channel "" 66 | puts $channel " hunter2 help for help with an action" 67 | } else { 68 | set args [info args $command] 69 | set printArgs [list] 70 | foreach arg $args { 71 | if {$arg == "args"} { 72 | set arg "userList" 73 | } 74 | lappend printArgs "<$arg>" 75 | } 76 | 77 | puts $channel "Usage: hunter2 $command [join $printArgs]" 78 | 79 | if {[llength $args] > 0} { 80 | puts $channel "" 81 | puts $channel "Arguments:" 82 | foreach arg $args { 83 | puts $channel " [_argDescription $command $arg]" 84 | } 85 | } 86 | } 87 | } 88 | 89 | if {[llength $argv] < 2} { 90 | _printHelp stderr "" 91 | 92 | exit 1 93 | } 94 | 95 | set argv [lrange $argv 2 end] 96 | 97 | package require sqlite3 98 | package require platform 99 | 100 | lappend ::auto_path [file join [file dirname [info script]] lib [platform::identify]] 101 | lappend ::auto_path [file join [file dirname [info script]] lib [platform::generic]] 102 | lappend ::auto_path [file join [file dirname [info script]] lib] 103 | 104 | package require pki 105 | package require pki::pkcs11 106 | package require aes 107 | 108 | # Backports for older versions of "pki" 109 | proc ::pki::pkcs::parse_public_key {key {password ""}} { 110 | array set parsed_key [::pki::_parse_pem $key "-----BEGIN PUBLIC KEY-----" "-----END PUBLIC KEY-----" $password] 111 | 112 | set key_seq $parsed_key(data) 113 | 114 | ::asn::asnGetSequence key_seq pubkeyinfo 115 | ::asn::asnGetSequence pubkeyinfo pubkey_algoid 116 | ::asn::asnGetObjectIdentifier pubkey_algoid oid 117 | ::asn::asnGetBitString pubkeyinfo pubkey 118 | set ret(pubkey_algo) [::pki::_oid_number_to_name $oid] 119 | 120 | switch -- $ret(pubkey_algo) { 121 | "rsaEncryption" { 122 | set pubkey [binary format B* $pubkey] 123 | 124 | ::asn::asnGetSequence pubkey pubkey_parts 125 | ::asn::asnGetBigInteger pubkey_parts ret(n) 126 | ::asn::asnGetBigInteger pubkey_parts ret(e) 127 | 128 | set ret(n) [::math::bignum::tostr $ret(n)] 129 | set ret(e) [::math::bignum::tostr $ret(e)] 130 | set ret(l) [expr {int([::pki::_bits $ret(n)] / 8.0000 + 0.5) * 8}] 131 | set ret(type) rsa 132 | } 133 | default { 134 | error "Unknown algorithm" 135 | } 136 | } 137 | 138 | return [array get ret] 139 | } 140 | 141 | proc ::pki::rsa::serialize_public_key {keylist} { 142 | array set key $keylist 143 | 144 | foreach entry [list n e] { 145 | if {![info exists key($entry)]} { 146 | return -code error "Key does not contain an element $entry" 147 | } 148 | } 149 | 150 | set pubkey [::asn::asnSequence \ 151 | [::asn::asnBigInteger [::math::bignum::fromstr $key(n)]] \ 152 | [::asn::asnBigInteger [::math::bignum::fromstr $key(e)]] \ 153 | ] 154 | set pubkey_algo_params [::asn::asnNull] 155 | 156 | binary scan $pubkey B* pubkey_bitstring 157 | 158 | set ret [::asn::asnSequence \ 159 | [::asn::asnSequence \ 160 | [::asn::asnObjectIdentifier [::pki::_oid_name_to_number rsaEncryption]] \ 161 | $pubkey_algo_params \ 162 | ] \ 163 | [::asn::asnBitString $pubkey_bitstring] \ 164 | ] 165 | 166 | return [list data $ret begin "-----BEGIN PUBLIC KEY-----" end "-----END PUBLIC KEY-----"] 167 | } 168 | # End backports 169 | 170 | # Start internal functions 171 | proc _listCertificates {} { 172 | if {![info exists ::env(PKCS11MODULE)]} { 173 | return [list] 174 | } 175 | 176 | set ::env(CACKEY_NO_EXTRA_CERTS) 1 177 | 178 | set handle [::pki::pkcs11::loadmodule $::env(PKCS11MODULE)] 179 | 180 | set slotInfo [list] 181 | foreach slot [::pki::pkcs11::listslots $handle] { 182 | set slotID [lindex $slot 0] 183 | set slotLabel [lindex $slot 1] 184 | set slotFlags [lindex $slot 2] 185 | 186 | if {"TOKEN_PRESENT" ni $slotFlags} { 187 | continue 188 | } 189 | 190 | if {"TOKEN_INITIALIZED" ni $slotFlags} { 191 | continue 192 | } 193 | 194 | set slotPromptForPIN false 195 | if {"PROTECTED_AUTHENTICATION_PATH" ni $slotFlags} { 196 | if {"LOGIN_REQUIRED" in $slotFlags} { 197 | set slotPromptForPIN true 198 | } 199 | } 200 | 201 | foreach cert [::pki::pkcs11::listcerts $handle $slotID] { 202 | set pubkey [binary encode base64 [dict get [::pki::rsa::serialize_public_key $cert] data]] 203 | 204 | lappend slotInfo [list handle $handle id $slotID prompt $slotPromptForPIN cert $cert pubkey $pubkey] 205 | } 206 | } 207 | 208 | return $slotInfo 209 | } 210 | 211 | proc _addPassword {name password publicKeys} { 212 | set fd [open "/dev/urandom" r] 213 | fconfigure $fd -translation binary 214 | 215 | db eval {DELETE FROM passwords WHERE name = $name;} 216 | 217 | foreach publicKey $publicKeys { 218 | set key [read $fd 16] 219 | if {[string length $key] != 16} { 220 | close $fd 221 | 222 | return -code error "ERROR: Short read from random device" 223 | } 224 | 225 | set publicKeyItem [::pki::pkcs::parse_public_key [binary decode base64 $publicKey]] 226 | 227 | set encryptedKey [binary encode base64 [::pki::encrypt -pub -binary -- $key $publicKeyItem]] 228 | 229 | set encryptedPass [binary encode base64 [::aes::aes -dir encrypt -key $key -- $password]] 230 | 231 | db eval {INSERT INTO passwords (name, encryptedPass, encryptedKey, publicKey) VALUES ($name, @encryptedPass, @encryptedKey, @publicKey);} 232 | } 233 | 234 | close $fd 235 | } 236 | 237 | proc _prompt {prompt} { 238 | puts -nonewline $prompt 239 | flush stdout 240 | 241 | puts -nonewline [exec stty -echo] 242 | flush stdout 243 | 244 | set password [gets stdin] 245 | 246 | puts -nonewline [exec stty echo] 247 | puts "" 248 | flush stdout 249 | 250 | return $password 251 | } 252 | 253 | proc _getPassword {name} { 254 | set exists [db eval {SELECT 1 FROM passwords WHERE name = $name LIMIT 1;}] 255 | if {$exists != "1"} { 256 | return -code error "Password \"$name\" does not exists." 257 | } 258 | 259 | foreach slotInfoDict [_listCertificates] { 260 | unset -nocomplain slotInfo 261 | array set slotInfo $slotInfoDict 262 | 263 | set pubkey $slotInfo(pubkey) 264 | set prompt $slotInfo(prompt) 265 | 266 | if {[info exists prompted($slotInfo(id))]} { 267 | set prompt false 268 | } 269 | 270 | if {$prompt} { 271 | set PIN [_prompt "Please enter the PIN for [dict get $slotInfo(cert) subject]: "] 272 | 273 | if {![::pki::pkcs11::login $slotInfo(handle) $slotInfo(id) $PIN]} { 274 | return -code error "Unable to authenticate" 275 | } 276 | 277 | set prompted($slotInfo(id)) 1 278 | } 279 | 280 | db eval {SELECT encryptedPass, encryptedKey FROM passwords WHERE name = $name AND publicKey = $pubkey;} row { 281 | set key [::pki::decrypt -binary -priv -- [binary decode base64 $row(encryptedKey)] $slotInfo(cert)] 282 | set password [::aes::aes -dir decrypt -key $key -- [binary decode base64 $row(encryptedPass)]] 283 | 284 | return $password 285 | } 286 | } 287 | 288 | return -code error "No valid keys" 289 | } 290 | 291 | proc _modifyPublicKeys {passwordName userNames sql} { 292 | set exists [db eval {SELECT 1 FROM passwords WHERE name = $passwordName LIMIT 1;}] 293 | if {$exists != "1"} { 294 | return -code error "Password \"$passwordName\" does not exists." 295 | } 296 | 297 | set publicKeys [list] 298 | 299 | db eval {SELECT publicKey FROM passwords WHERE name = $passwordName;} row { 300 | lappend publicKeys $row(publicKey) 301 | } 302 | 303 | set changeRequired 0 304 | foreach user $userNames { 305 | unset -nocomplain row 306 | db eval {SELECT publicKey FROM users WHERE name = $user;} row $sql 307 | } 308 | 309 | if {!$changeRequired} { 310 | return 311 | } 312 | 313 | set password [_getPassword $passwordName] 314 | 315 | _addPassword $passwordName $password $publicKeys 316 | } 317 | 318 | proc _getUsersForPassword {passwordNames} { 319 | set userNames [list] 320 | 321 | foreach passwordName $passwordNames { 322 | db eval {SELECT publicKey FROM passwords WHERE name = $passwordName;} passwordRow { 323 | db eval {SELECT name FROM users WHERE publicKey = $passwordRow(publicKey)} userRow { 324 | if {$userRow(name) in $userNames} { 325 | continue 326 | } 327 | 328 | lappend userNames $userRow(name) 329 | } 330 | } 331 | } 332 | 333 | return $userNames 334 | } 335 | 336 | proc _getPasswordsForUser {userNames} { 337 | set passwordNames [list] 338 | 339 | foreach userName $userNames { 340 | db eval {SELECT publicKey FROM users WHERE name = $userName;} userRow { 341 | db eval {SELECT name FROM passwords WHERE publicKey = $userRow(publicKey)} passwordRow { 342 | if {$passwordRow(name) in $passwordNames} { 343 | continue 344 | } 345 | 346 | lappend passwordNames $passwordRow(name) 347 | } 348 | } 349 | } 350 | 351 | return $passwordNames 352 | } 353 | # End internal functions 354 | 355 | # Start user CLI functions 356 | proc listLocalKeys {} { 357 | foreach slotInfoDict [_listCertificates] { 358 | unset -nocomplain slotInfo 359 | array set slotInfo $slotInfoDict 360 | 361 | set subject [dict get $slotInfo(cert) subject] 362 | set pubkey $slotInfo(pubkey) 363 | 364 | lappend publicKeys($subject) $pubkey 365 | } 366 | 367 | foreach {subject pubkeys} [array get publicKeys] { 368 | puts "$subject" 369 | 370 | foreach pubkey $pubkeys { 371 | puts " |-> $pubkey" 372 | } 373 | } 374 | } 375 | 376 | proc listAvailablePasswords {} { 377 | set passwordNames [list] 378 | foreach slotInfoDict [_listCertificates] { 379 | unset -nocomplain slotInfo 380 | array set slotInfo $slotInfoDict 381 | 382 | set pubkey $slotInfo(pubkey) 383 | 384 | unset -nocomplain row 385 | db eval {SELECT name FROM passwords WHERE publicKey = $pubkey;} row { 386 | if {$row(name) in $passwordNames} { 387 | continue 388 | } 389 | 390 | lappend passwordNames $row(name) 391 | } 392 | } 393 | 394 | 395 | foreach passwordName $passwordNames { 396 | puts "$passwordName - [join [_getUsersForPassword [list $passwordName]] {, }]" 397 | } 398 | } 399 | 400 | proc listPasswords {} { 401 | db eval {SELECT DISTINCT name FROM passwords;} row { 402 | puts "$row(name) - [join [_getUsersForPassword [list $row(name)]] {, }]" 403 | } 404 | } 405 | 406 | proc listUsers {} { 407 | db eval {SELECT DISTINCT name FROM users;} row { 408 | puts "$row(name) - [join [_getPasswordsForUser [list $row(name)]] {, }]" 409 | } 410 | } 411 | 412 | proc addUser {userName key} { 413 | set keyRaw [binary decode base64 $key] 414 | set keyVerify [::pki::pkcs::parse_public_key $keyRaw] 415 | 416 | db eval {INSERT INTO users (name, publicKey) VALUES ($userName, @key);} 417 | 418 | # XXX:TODO:Go through and re-authorize if possible 419 | } 420 | 421 | proc deleteUser {userName} { 422 | # XXX:TODO: Go through and de-authorize 423 | } 424 | 425 | proc addPassword {passwordName password args} { 426 | set initialUsers $args 427 | 428 | if {$password eq ""} { 429 | set password [_prompt "Please enter the new password: "] 430 | } 431 | 432 | # Verify that this password does not already exist 433 | set exists [db eval {SELECT 1 FROM passwords WHERE name = $passwordName LIMIT 1;}] 434 | if {$exists == "1"} { 435 | return -code error "Password \"$passwordName\" already exists, cannot add." 436 | } 437 | 438 | # Get keys for initial users 439 | set publicKeys [list] 440 | foreach user $initialUsers { 441 | unset -nocomplain row 442 | db eval {SELECT publicKey FROM users WHERE name = $user;} row { 443 | lappend publicKeys $row(publicKey) 444 | } 445 | } 446 | 447 | _addPassword $passwordName $password $publicKeys 448 | } 449 | 450 | proc getPassword {passwordName} { 451 | puts [_getPassword $passwordName] 452 | } 453 | 454 | proc updatePassword {passwordName password} { 455 | if {$password eq ""} { 456 | set password [_prompt "Please enter the new password: "] 457 | } 458 | 459 | db eval {SELECT publicKey FROM passwords WHERE name = $passwordName;} row { 460 | lappend publicKeys $row(publicKey) 461 | } 462 | 463 | _addPassword $passwordName $password $publicKeys 464 | } 465 | 466 | proc deletePassword {passwordName} { 467 | db eval {DELETE FROM passwords WHERE name = $passwordName;} 468 | } 469 | 470 | proc authorizeUsers {passwordName args} { 471 | set users $args 472 | 473 | _modifyPublicKeys $passwordName $users { 474 | if {$row(publicKey) in $publicKeys} { 475 | continue 476 | } 477 | 478 | lappend publicKeys $row(publicKey) 479 | 480 | set changeRequired 1 481 | } 482 | } 483 | 484 | proc authorizeUser {passwordName userName} { 485 | return [authorizeUsers $passwordName $userName] 486 | } 487 | 488 | proc deauthorizeUsers {passwordName args} { 489 | set users $args 490 | 491 | _modifyPublicKeys $passwordName $users { 492 | set idx [lsearch -exact $publicKeys $row(publicKey)] 493 | if {$idx == -1} { 494 | continue 495 | } 496 | 497 | set publicKeys [lreplace $publicKeys $idx $idx] 498 | 499 | set changeRequired 1 500 | } 501 | } 502 | 503 | proc deauthorizeUser {passwordName userName} { 504 | return [deauthorizeUsers $passwordName $userName] 505 | } 506 | 507 | proc help {{action ""}} { 508 | _printHelp stdout $action 509 | } 510 | # End user CLI functions 511 | 512 | ### MAIN 513 | 514 | sqlite3 db $passwordFile 515 | 516 | db eval { 517 | CREATE TABLE IF NOT EXISTS users(name, publicKey BLOB); 518 | CREATE TABLE IF NOT EXISTS passwords(name, encryptedPass BLOB, encryptedKey BLOB, publicKey BLOB); 519 | } 520 | 521 | if {$action in $validCommands} { 522 | if {[catch { 523 | $action {*}$argv 524 | } error]} { 525 | puts stderr "Error: $error" 526 | 527 | exit 1 528 | } 529 | } else { 530 | puts stderr "Invalid action" 531 | 532 | exit 1 533 | } 534 | 535 | exit 0 536 | 537 | --------------------------------------------------------------------------------