├── .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 |
--------------------------------------------------------------------------------