noms-command 0.5.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.rst +104 -37
- data/TODO.rst +3 -3
- data/fixture/dnc.rb +110 -1
- data/fixture/public/dnc.json +6 -5
- data/fixture/public/lib/dnc.js +171 -24
- data/fixture/public/lib/nomsargs.js +72 -0
- data/lib/noms/command.rb +37 -8
- data/lib/noms/command/application.rb +32 -26
- data/lib/noms/command/auth.rb +44 -62
- data/lib/noms/command/auth/identity.rb +205 -5
- data/lib/noms/command/base.rb +11 -1
- data/lib/noms/command/formatter.rb +5 -4
- data/lib/noms/command/home.rb +21 -0
- data/lib/noms/command/useragent.rb +117 -40
- data/lib/noms/command/useragent/cache.rb +124 -0
- data/lib/noms/command/useragent/requester.rb +48 -0
- data/lib/noms/command/useragent/requester/httpclient.rb +61 -0
- data/lib/noms/command/useragent/requester/typhoeus.rb +73 -0
- data/lib/noms/command/useragent/response.rb +202 -0
- data/lib/noms/command/useragent/response/httpclient.rb +59 -0
- data/lib/noms/command/useragent/response/typhoeus.rb +74 -0
- data/lib/noms/command/version.rb +1 -1
- data/lib/noms/command/window.rb +21 -3
- data/lib/noms/command/xmlhttprequest.rb +8 -8
- data/noms-command.gemspec +3 -1
- data/spec/07js_spec.rb +1 -1
- data/spec/10auth_spec.rb +132 -0
- data/spec/11useragent_cache_spec.rb +160 -0
- data/spec/12useragent_auth_cookie_spec.rb +53 -0
- data/spec/13useragent_auth_spec.rb +90 -0
- data/spec/spec_helper.rb +5 -0
- metadata +46 -4
- data/fixture/public/lib/noms-args.js +0 -13
@@ -0,0 +1,72 @@
|
|
1
|
+
var nomsargs = { };
|
2
|
+
|
3
|
+
(function (self) {
|
4
|
+
|
5
|
+
function NomsArgs(args, opt) {
|
6
|
+
var comparisons = [ ];
|
7
|
+
var assignment = { };
|
8
|
+
var warnings = [ ];
|
9
|
+
var errors = [ ];
|
10
|
+
var extra = [ ];
|
11
|
+
|
12
|
+
args.map(function (item) {
|
13
|
+
var match = /^([^!=~><]+)(==|=|!=|>=|<=|~|!~)(.*)$/.exec(item)
|
14
|
+
if (match == null) {
|
15
|
+
extra.push(item);
|
16
|
+
} else {
|
17
|
+
var m_field = match[1];
|
18
|
+
var m_op = match[2];
|
19
|
+
var m_rvalue = match[3];
|
20
|
+
|
21
|
+
comparisons.push({
|
22
|
+
field: m_field,
|
23
|
+
op: m_op,
|
24
|
+
rvalue: m_rvalue
|
25
|
+
});
|
26
|
+
|
27
|
+
if (m_op == '=') {
|
28
|
+
assignment[m_field] = m_rvalue
|
29
|
+
}
|
30
|
+
}
|
31
|
+
});
|
32
|
+
|
33
|
+
this.comparisons = comparisons;
|
34
|
+
this.assignment = assignment;
|
35
|
+
this.warnings = warnings;
|
36
|
+
this.errors = errors;
|
37
|
+
this.extra = extra;
|
38
|
+
};
|
39
|
+
|
40
|
+
NomsArgs.prototype = {
|
41
|
+
keys: function () {
|
42
|
+
this.comparisons.map(function (comp) { return comp.field; });
|
43
|
+
},
|
44
|
+
assignmentKeys: function () {
|
45
|
+
var keys = [ ];
|
46
|
+
|
47
|
+
for (var key in this.assignment) {
|
48
|
+
if (this.assignment.hasOwnProperty(key)) {
|
49
|
+
keys.push(key);
|
50
|
+
}
|
51
|
+
}
|
52
|
+
|
53
|
+
return keys;
|
54
|
+
},
|
55
|
+
query: function () {
|
56
|
+
return encodeURI(this.comparisons.map(function (comp) {
|
57
|
+
return comp.field + comp.op + comp.rvalue;
|
58
|
+
}).join('&'));
|
59
|
+
},
|
60
|
+
assignmentQuery: function () {
|
61
|
+
return encodeURI(this.assignmentKeys().map(function (key) {
|
62
|
+
return key + '=' + this.assignment[key];
|
63
|
+
}).join('&'))
|
64
|
+
},
|
65
|
+
keys: function () {
|
66
|
+
return this.comparisons.map(function (comp) { comp.field });
|
67
|
+
}
|
68
|
+
};
|
69
|
+
|
70
|
+
self.NomsArgs = NomsArgs;
|
71
|
+
|
72
|
+
})(nomsargs);
|
data/lib/noms/command.rb
CHANGED
@@ -1,13 +1,22 @@
|
|
1
1
|
#!ruby
|
2
2
|
|
3
3
|
require 'noms/command/version'
|
4
|
+
require 'noms/command/home'
|
4
5
|
|
5
6
|
require 'trollop'
|
6
7
|
require 'logger'
|
7
8
|
|
9
|
+
require 'noms/command/base'
|
10
|
+
require 'noms/command/useragent'
|
11
|
+
|
8
12
|
require 'noms/command/window'
|
13
|
+
require 'noms/command/xmlhttprequest'
|
14
|
+
require 'noms/command/document'
|
15
|
+
|
9
16
|
require 'noms/command/application'
|
10
17
|
require 'noms/command/formatter'
|
18
|
+
require 'noms/command/auth'
|
19
|
+
require 'noms/command/auth/identity'
|
11
20
|
|
12
21
|
class NOMS
|
13
22
|
|
@@ -23,7 +32,7 @@ class NOMS::Command
|
|
23
32
|
def initialize(argv)
|
24
33
|
@argv = argv
|
25
34
|
@log = Logger.new($stderr)
|
26
|
-
@log.formatter = lambda { |sev, timestamp, prog, msg| msg[-1].chr
|
35
|
+
@log.formatter = lambda { |sev, timestamp, prog, msg| (msg.empty? or msg[-1].chr != "\n") ? msg + "\n" : msg }
|
27
36
|
end
|
28
37
|
|
29
38
|
def run
|
@@ -36,16 +45,26 @@ class NOMS::Command
|
|
36
45
|
noms [noms-options] { bookmark | url } [options] [arguments]
|
37
46
|
noms-options:
|
38
47
|
USAGE
|
39
|
-
opt :identity, "Identity file", :
|
40
|
-
|
41
|
-
|
48
|
+
opt :identity, "Identity file", :short => '-i',
|
49
|
+
:type => :string,
|
50
|
+
:multi => true
|
51
|
+
opt :logout, "Log out of authentication sessions", :short => '-L'
|
52
|
+
opt :verbose, "Enable verbose output", :short => '-v'
|
53
|
+
opt :list, "List bookmarks", :short => '-l'
|
42
54
|
opt :bookmarks, "Bookmark file location (can be specified multiple times)",
|
55
|
+
:short => '-b',
|
43
56
|
:type => :string,
|
44
57
|
:multi => true
|
58
|
+
opt :home, "Use directory as NOMS_HOME instead of #{NOMS::Command.home}",
|
59
|
+
:short => '-H',
|
60
|
+
:type => :string
|
61
|
+
opt :nocache, "Don't cache files",
|
62
|
+
:short => '-C'
|
45
63
|
opt :nodefault_bookmarks, "Don't consult default bookmarks files",
|
46
64
|
:short => '-X',
|
47
65
|
:long => '--nodefault-bookmarks'
|
48
|
-
opt :debug, "Enable debug output"
|
66
|
+
opt :debug, "Enable debug output", :short => '-d'
|
67
|
+
opt :'plaintext-identity', "Save identity credentials in plaintext", :short => '-P'
|
49
68
|
stop_on_unknown
|
50
69
|
end
|
51
70
|
|
@@ -53,14 +72,16 @@ class NOMS::Command
|
|
53
72
|
parser.parse(@argv)
|
54
73
|
end
|
55
74
|
|
75
|
+
NOMS::Command.home = @opt[:home] if @opt[:home]
|
76
|
+
|
56
77
|
Trollop::with_standard_exception_handling parser do
|
57
|
-
raise Trollop::HelpNeeded if @argv.empty? and ! @opt[:list]
|
78
|
+
raise Trollop::HelpNeeded if @argv.empty? and ! @opt[:list] and ! @opt[:logout]
|
58
79
|
end
|
59
80
|
|
60
81
|
@opt[:debug] = true if ENV['NOMS_DEBUG'] and ! ENV['NOMS_DEBUG'].empty?
|
61
82
|
|
62
83
|
default_bookmarks =
|
63
|
-
[ File.join(
|
84
|
+
[ File.join(NOMS::Command.home, 'bookmarks.json'),
|
64
85
|
'/usr/local/etc/noms/bookmarks.json',
|
65
86
|
'/etc/noms/bookmarks.json'].select { |f| File.exist? f }
|
66
87
|
|
@@ -89,10 +110,18 @@ class NOMS::Command
|
|
89
110
|
return 0
|
90
111
|
end
|
91
112
|
|
113
|
+
if @opt[:logout]
|
114
|
+
File.unlink NOMS::Command::Auth::Identity.vault_keyfile if
|
115
|
+
File.exist? NOMS::Command::Auth::Identity.vault_keyfile
|
116
|
+
return 0
|
117
|
+
end
|
118
|
+
|
92
119
|
begin
|
93
120
|
origin = @bookmark[@argv[0].split('/').first] || @argv[0]
|
94
121
|
app = NOMS::Command::Application.new(origin, @argv, :logger => @log,
|
95
|
-
:specified_identities => @opt[:identity]
|
122
|
+
:specified_identities => @opt[:identity],
|
123
|
+
:cache => ! @opt[:nocache],
|
124
|
+
:plaintext_identity => @opt[:'plaintext-identity'])
|
96
125
|
app.fetch! # Retrieve page
|
97
126
|
app.render! # Run scripts
|
98
127
|
out = app.display
|
@@ -5,14 +5,7 @@ require 'noms/command/version'
|
|
5
5
|
require 'mime-types'
|
6
6
|
require 'v8'
|
7
7
|
|
8
|
-
require 'noms/command
|
9
|
-
require 'noms/command/useragent'
|
10
|
-
require 'noms/command/error'
|
11
|
-
require 'noms/command/urinion'
|
12
|
-
require 'noms/command/formatter'
|
13
|
-
require 'noms/command/document'
|
14
|
-
require 'noms/command/xmlhttprequest'
|
15
|
-
require 'noms/command/base'
|
8
|
+
require 'noms/command'
|
16
9
|
|
17
10
|
class NOMS
|
18
11
|
|
@@ -45,7 +38,10 @@ class NOMS::Command::Application < NOMS::Command::Base
|
|
45
38
|
|
46
39
|
@log.debug "Application #{argv[0]} has origin: #{origin}"
|
47
40
|
@useragent = NOMS::Command::UserAgent.new(@origin, :logger => @log,
|
48
|
-
:specified_identities => (attrs[:specified_identities] || [])
|
41
|
+
:specified_identities => (attrs[:specified_identities] || []),
|
42
|
+
:cache => (attrs.has_key?(:cache) ? attrs[:cache] : true),
|
43
|
+
:plaintext_identity => (attrs.has_key?(:plaintext_identity) ?
|
44
|
+
attrs[:plaintext_identity] : false))
|
49
45
|
end
|
50
46
|
|
51
47
|
def fetch!
|
@@ -64,15 +60,14 @@ class NOMS::Command::Application < NOMS::Command::Base
|
|
64
60
|
@origin = new_url
|
65
61
|
@useragent.origin = new_url
|
66
62
|
@window.origin = new_url
|
67
|
-
|
68
|
-
if response.ok?
|
63
|
+
if response.success?
|
69
64
|
# Unlike typical ReST data sources, this
|
70
65
|
# should very rarely fail unless there is
|
71
66
|
# a legitimate communication issue.
|
72
|
-
@type = response.
|
73
|
-
@body = response.
|
67
|
+
@type = response.content_type || 'text/plain'
|
68
|
+
@body = response.body
|
74
69
|
else
|
75
|
-
raise NOMS::Command::Error.new("Failed to request #{@origin}: #{response.
|
70
|
+
raise NOMS::Command::Error.new("Failed to request #{@origin}: #{response.statusText}")
|
76
71
|
end
|
77
72
|
else
|
78
73
|
raise NOMS::Command::Error.new("noms command #{@argv[0].inspect} not found: not a URL or bookmark")
|
@@ -87,12 +82,10 @@ class NOMS::Command::Application < NOMS::Command::Base
|
|
87
82
|
end
|
88
83
|
if @body.respond_to? :has_key? and @body.has_key? '$doctype'
|
89
84
|
@type = @body['$doctype']
|
90
|
-
@log.debug "Treating as #{@type} document"
|
91
85
|
@document = NOMS::Command::Document.new @body
|
92
86
|
@document.argv = @argv
|
93
87
|
@document.exitcode = 0
|
94
88
|
else
|
95
|
-
@log.debug "Treating as raw object (no '$doctype')"
|
96
89
|
@type = 'noms-raw'
|
97
90
|
end
|
98
91
|
end
|
@@ -122,26 +115,38 @@ class NOMS::Command::Application < NOMS::Command::Base
|
|
122
115
|
@document.script.each do |script|
|
123
116
|
if script.respond_to? :has_key? and script.has_key? '$source'
|
124
117
|
# Parse relative URL and load
|
125
|
-
|
118
|
+
request_error = nil
|
119
|
+
begin
|
120
|
+
response, landing_url = @useragent.get(script['$source'])
|
121
|
+
rescue StandardError => e
|
122
|
+
@log.debug "Setting request_error (#{e.class}) to #{e.message})"
|
123
|
+
request_error = e
|
124
|
+
end
|
126
125
|
# Don't need landing_url
|
127
126
|
script_name = File.basename(@useragent.absolute_url(script['$source']).path)
|
128
127
|
script_ref = "#{script_index},#{script_name}"
|
129
|
-
if response.
|
130
|
-
case response.
|
128
|
+
if request_error.nil? and response.success?
|
129
|
+
case response.content_type
|
131
130
|
when /^(application|text)\/(x-|)javascript/
|
132
131
|
begin
|
133
|
-
@v8.eval response.
|
132
|
+
@v8.eval response.body
|
134
133
|
rescue StandardError => e
|
135
134
|
@log.warn "Javascript[#{script_ref}] error: #{e.message}"
|
136
|
-
@log.debug e.backtrace.join("\n")
|
135
|
+
@log.debug { e.backtrace.join("\n") }
|
137
136
|
end
|
138
137
|
else
|
139
|
-
@log.warn "Unsupported script type '#{response.
|
138
|
+
@log.warn "Unsupported script type '#{response.content_type.inspect}' " +
|
140
139
|
"for script from #{script['$source'].inspect}"
|
141
140
|
end
|
142
141
|
else
|
143
|
-
|
144
|
-
|
142
|
+
if request_error
|
143
|
+
@log.warn "Couldn't load script from #{script['$source'].inspect} " +
|
144
|
+
"(#{request_error.class}): #{request_error.message})"
|
145
|
+
@log.debug { request_error.backtrace.join("\n") }
|
146
|
+
else
|
147
|
+
@log.warn "Couldn't load script from #{script['$source'].inspect}: " +
|
148
|
+
"#{response.statusText}"
|
149
|
+
end
|
145
150
|
end
|
146
151
|
else
|
147
152
|
# It's javascript text
|
@@ -150,7 +155,7 @@ class NOMS::Command::Application < NOMS::Command::Base
|
|
150
155
|
@v8.eval script
|
151
156
|
rescue StandardError => e
|
152
157
|
@log.warn "Javascript[#{script_ref}] error: #{e.message}"
|
153
|
-
@log.debug e.backtrace.join("\n")
|
158
|
+
@log.debug { e.backtrace.join("\n") }
|
154
159
|
end
|
155
160
|
end
|
156
161
|
script_index += 1
|
@@ -169,7 +174,8 @@ class NOMS::Command::Application < NOMS::Command::Base
|
|
169
174
|
def display
|
170
175
|
case @type
|
171
176
|
when 'noms-v2'
|
172
|
-
|
177
|
+
body = _sanitize(@document.body)
|
178
|
+
NOMS::Command::Formatter.new(body, :logger => @log).render
|
173
179
|
when 'noms-raw'
|
174
180
|
@body.to_yaml
|
175
181
|
when /^text(\/|$)/
|
data/lib/noms/command/auth.rb
CHANGED
@@ -4,7 +4,7 @@ require 'noms/command/version'
|
|
4
4
|
|
5
5
|
require 'httpclient'
|
6
6
|
require 'etc'
|
7
|
-
require 'highline
|
7
|
+
require 'highline'
|
8
8
|
require 'json'
|
9
9
|
require 'cgi'
|
10
10
|
|
@@ -23,48 +23,22 @@ class NOMS::Command::Auth < NOMS::Command::Base
|
|
23
23
|
|
24
24
|
def initialize(opts={})
|
25
25
|
@log = opts[:logger] || default_logger
|
26
|
-
@
|
26
|
+
@specified = { }
|
27
|
+
@input = opts[:prompt_input] || $stdin
|
28
|
+
@output = opts[:prompt_output] || $stdout
|
29
|
+
@force_prompt = opts.has_key?(:force_prompt) ? opts[:force_prompt] : false
|
30
|
+
|
27
31
|
(opts[:specified_identities] || []).each do |file|
|
28
|
-
maybe_id =
|
32
|
+
maybe_id = NOMS::Command::Auth::Identity.from file
|
29
33
|
raise NOMS::Command::Error.now "#{file} contains invalid identity (no 'id')" unless
|
30
34
|
maybe_id['id']
|
31
|
-
@
|
35
|
+
@specified[maybe_id['id']] = maybe_id
|
32
36
|
end
|
33
37
|
end
|
34
38
|
|
35
|
-
def read_identity_from(file)
|
36
|
-
@log.debug "Reading identity file #{file}"
|
37
|
-
begin
|
38
|
-
# TODO: Encryption and passphrases
|
39
|
-
raise NOMS::Command::Error.new "Identity file #{file} does not exist" unless File.exist? file
|
40
|
-
s = File.stat file
|
41
|
-
raise NOMS::Command::Error.new "You don't own identity file #{file}" unless s.owned?
|
42
|
-
raise NOMS::Command::Error.new "Permissions on #{file} are too permissive" unless (s.mode & 077 == 0)
|
43
|
-
contents = File.read file
|
44
|
-
case contents[0].chr
|
45
|
-
when '{'
|
46
|
-
NOMS::Command::Auth::Identity.new(self, JSON.parse(contents))
|
47
|
-
else
|
48
|
-
raise NOMS::Command::Error.new "#{file} contains unsupported or corrupted data"
|
49
|
-
end
|
50
|
-
rescue StandardError => e
|
51
|
-
if e.is_a? NOMS::Command::Error
|
52
|
-
raise e
|
53
|
-
else
|
54
|
-
raise NOMS::Command::Error.new "Couldn't load identity from #{file} (#{e.class}): #{e.message}"
|
55
|
-
end
|
56
|
-
end
|
57
|
-
end
|
58
|
-
|
59
|
-
# TODO: Persistent auth creds
|
60
|
-
# Store like a client certificate: encrypted. Then use an
|
61
|
-
# agent to store by using <agent>-add and typing passphrase
|
62
|
-
# just like a client cert. <agent> expires credentials.
|
63
|
-
# also you can explicitly unencrypt identity file
|
64
|
-
|
65
39
|
def load(url, response)
|
66
40
|
# Prompt
|
67
|
-
auth_header = response.header
|
41
|
+
auth_header = response.header('WWW-Authenticate')
|
68
42
|
auth_header = (auth_header.respond_to?(:first) ? auth_header.first : auth_header)
|
69
43
|
case auth_header
|
70
44
|
when /Basic/
|
@@ -75,30 +49,42 @@ class NOMS::Command::Auth < NOMS::Command::Base
|
|
75
49
|
end
|
76
50
|
domain = [url.scheme, '://', url.host, ':', url.port, '/'].join('')
|
77
51
|
identity_id = CGI.escape(realm) + '=' + domain
|
78
|
-
|
79
|
-
|
52
|
+
unless @specified.empty?
|
53
|
+
if @specified[identity_id]
|
54
|
+
@specified[identity_id]
|
55
|
+
else
|
56
|
+
@log.warn "No identity specified for #{domain} (#{realm})"
|
57
|
+
nil
|
58
|
+
end
|
80
59
|
else
|
81
|
-
if
|
82
|
-
|
83
|
-
prompt = "#{domain} (#{realm}) username: "
|
84
|
-
user = ask(prompt) { |u| u.default = Etc.getlogin }
|
85
|
-
pass = ask('Password: ') { |p| p.echo = false }
|
86
|
-
NOMS::Command::Auth::Identity.new(self, {
|
87
|
-
'id' => identity_id,
|
88
|
-
'realm' => realm,
|
89
|
-
'domain' => domain,
|
90
|
-
'username' => user,
|
91
|
-
'password' => pass
|
92
|
-
})
|
60
|
+
if id_info = saved(identity_id)
|
61
|
+
NOMS::Command::Auth::Identity.new(id_info, :logger => @log)
|
93
62
|
else
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
63
|
+
# It might be nice to synchronize around this to
|
64
|
+
# make pipelines work.
|
65
|
+
if $stdin.tty? or @force_prompt
|
66
|
+
highline = HighLine.new(@input, @output)
|
67
|
+
default_user = Etc.getlogin
|
68
|
+
prompt = "#{domain} (#{realm}) username: "
|
69
|
+
user = highline.ask(prompt) { |u| u.default = Etc.getlogin }
|
70
|
+
pass = highline.ask('Password: ') { |p| p.echo = false }
|
71
|
+
NOMS::Command::Auth::Identity.new({
|
72
|
+
'id' => identity_id,
|
73
|
+
'realm' => realm,
|
74
|
+
'domain' => domain,
|
75
|
+
'username' => user,
|
76
|
+
'password' => pass
|
77
|
+
}, :logger => @log)
|
78
|
+
else
|
79
|
+
@log.warn "Can't prompt for #{domain} (#{realm}) authentication (not a terminal)"
|
80
|
+
NOMS::Command::Auth::Identity.new({
|
81
|
+
'id' => identity_id,
|
82
|
+
'realm' => realm,
|
83
|
+
'domain' => domain,
|
84
|
+
'username' => '',
|
85
|
+
'password' => ''
|
86
|
+
}, :logger => @log)
|
87
|
+
end
|
102
88
|
end
|
103
89
|
end
|
104
90
|
else
|
@@ -107,11 +93,7 @@ class NOMS::Command::Auth < NOMS::Command::Base
|
|
107
93
|
end
|
108
94
|
|
109
95
|
def saved(identity_id)
|
110
|
-
|
111
|
-
end
|
112
|
-
|
113
|
-
def retrieve(identity_id)
|
114
|
-
@loaded[identity_id]
|
96
|
+
NOMS::Command::Auth::Identity.saved identity_id
|
115
97
|
end
|
116
98
|
|
117
99
|
end
|
@@ -1,6 +1,22 @@
|
|
1
1
|
#!ruby
|
2
2
|
|
3
|
+
require 'noms/command/version'
|
4
|
+
require 'noms/command/home'
|
5
|
+
|
6
|
+
require 'fileutils'
|
3
7
|
require 'logger'
|
8
|
+
require 'openssl'
|
9
|
+
require 'fcntl'
|
10
|
+
require 'base64'
|
11
|
+
require 'bcrypt'
|
12
|
+
|
13
|
+
require 'noms/command'
|
14
|
+
|
15
|
+
class String
|
16
|
+
def to_hex
|
17
|
+
self.unpack('C*').map { |n| '%02x' % n }.join('')
|
18
|
+
end
|
19
|
+
end
|
4
20
|
|
5
21
|
class NOMS
|
6
22
|
|
@@ -17,10 +33,144 @@ end
|
|
17
33
|
class NOMS::Command::Auth::Identity < NOMS::Command::Base
|
18
34
|
include Enumerable
|
19
35
|
|
20
|
-
|
36
|
+
@@identity_dir = File.join(NOMS::Command.home, 'identities')
|
37
|
+
@@cipher = 'aes-256-cfb'
|
38
|
+
@@hmac_digest = 'sha256'
|
39
|
+
@@max_key_idle = 3600
|
40
|
+
|
41
|
+
def self.identity_dir
|
42
|
+
@@identity_dir
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.identity_dir=(value)
|
46
|
+
@@identity_dir = value
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.generate_new_key
|
50
|
+
cipher = OpenSSL::Cipher.new(@@cipher)
|
51
|
+
cipher.encrypt
|
52
|
+
cipher.random_key + cipher.random_key
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.vault_keyfile
|
56
|
+
File.join(@@identity_dir, '.noms-vault-key')
|
57
|
+
end
|
58
|
+
|
59
|
+
def self.ensure_dir
|
60
|
+
unless File.directory? @@identity_dir
|
61
|
+
FileUtils.mkdir_p @@identity_dir
|
62
|
+
end
|
63
|
+
File.chmod 0700, @@identity_dir
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.get_vault_key
|
67
|
+
key = ''
|
68
|
+
ensure_dir
|
69
|
+
fh = File.for_fd(IO.sysopen(vault_keyfile, Fcntl::O_RDWR | Fcntl::O_CREAT, 0600))
|
70
|
+
fh.flock(File::LOCK_EX)
|
71
|
+
mtime = fh.mtime
|
72
|
+
|
73
|
+
key = fh.read if (Time.now - mtime < @@max_key_idle)
|
74
|
+
|
75
|
+
if key.empty?
|
76
|
+
key = generate_new_key
|
77
|
+
fh.write key
|
78
|
+
end
|
79
|
+
fh.flock(File::LOCK_EX)
|
80
|
+
fh.close
|
81
|
+
|
82
|
+
key
|
83
|
+
end
|
84
|
+
|
85
|
+
def self.decrypt(blob)
|
86
|
+
mac_key, enc_key = get_vault_key.unpack('a32a32')
|
87
|
+
|
88
|
+
message = Base64.decode64(blob)
|
89
|
+
hmac_digest, iv, data = message.unpack('a32a16a*')
|
90
|
+
|
91
|
+
hmac = OpenSSL::HMAC.new(mac_key, OpenSSL::Digest.new(@@hmac_digest))
|
92
|
+
hmac.update(iv + data)
|
93
|
+
raise NOMS::Command::Error.new("HMAC verification error") unless hmac.digest == hmac_digest
|
94
|
+
|
95
|
+
cipher = OpenSSL::Cipher.new @@cipher
|
96
|
+
cipher.decrypt
|
97
|
+
cipher.key = enc_key
|
98
|
+
cipher.iv = iv
|
99
|
+
cipher.update(data) + cipher.final
|
100
|
+
end
|
101
|
+
|
102
|
+
|
103
|
+
# Returns hash of identity data suitable for passing to .new
|
104
|
+
def self.saved(identity_id, opt={})
|
105
|
+
# This can't really log errors, hm.
|
106
|
+
id_number = OpenSSL::Digest::SHA1.new(identity_id).hexdigest
|
107
|
+
file_base = File.join(@@identity_dir, id_number)
|
108
|
+
|
109
|
+
if File.exist? "#{file_base}.json"
|
110
|
+
begin
|
111
|
+
JSON.parse(File.read "#{file_base}.json").merge({ '_loaded' => { "#{file_base}.json" => Time.now.to_s } })
|
112
|
+
rescue StandardError => e
|
113
|
+
File.unlink "#{file_base}.json" if File.exist? "#{file_base}.json"
|
114
|
+
return nil
|
115
|
+
end
|
116
|
+
elsif File.exist? "#{file_base}.enc"
|
117
|
+
begin
|
118
|
+
hash = JSON.parse(decrypt(File.read("#{file_base}.enc"))).
|
119
|
+
merge({ '_decrypted' => true, '_loaded' => { "#{file_base}.enc" => Time.now.to_s } })
|
120
|
+
rescue StandardError => e
|
121
|
+
File.unlink "#{file_base}.enc" if File.exist? "#{file_base}.enc"
|
122
|
+
return nil
|
123
|
+
end
|
124
|
+
else
|
125
|
+
return nil
|
126
|
+
end
|
127
|
+
|
128
|
+
end
|
129
|
+
|
130
|
+
def self.from(file)
|
131
|
+
begin
|
132
|
+
raise NOMS::Command::Error.new "Identity file #{file} does not exist" unless File.exist? file
|
133
|
+
s = File.stat file
|
134
|
+
raise NOMS::Command::Error.new "You don't own identity file #{file}" unless s.owned?
|
135
|
+
raise NOMS::Command::Error.new "Permissions on #{file} are too permissive" unless (s.mode & 077 == 0)
|
136
|
+
contents = File.read file
|
137
|
+
raise NOMS::Command::Error.new "#{file} is empty" unless contents and ! contents.empty?
|
138
|
+
case contents[0].chr
|
139
|
+
when '{'
|
140
|
+
NOMS::Command::Auth::Identity.new(JSON.parse(contents).merge({'_specified' => file }), :logger => @log)
|
141
|
+
else
|
142
|
+
raise NOMS::Command::Error.new "#{file} contains unsupported or corrupted data"
|
143
|
+
end
|
144
|
+
rescue StandardError => e
|
145
|
+
if e.is_a? NOMS::Command::Error
|
146
|
+
raise e
|
147
|
+
else
|
148
|
+
raise NOMS::Command::Error.new "Couldn't load identity from #{file} (#{e.class}): #{e.message}"
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def initialize(h, attrs={})
|
21
154
|
@log = attrs[:logger] || default_logger
|
22
|
-
@auth = auth
|
23
155
|
@data = h
|
156
|
+
refresh_vault_key if h['_decrypted']
|
157
|
+
end
|
158
|
+
|
159
|
+
def specified
|
160
|
+
@data['_specified']
|
161
|
+
end
|
162
|
+
|
163
|
+
def verification_hash
|
164
|
+
BCrypt::Password.create(self['username'] + ':' + self['password'] + '@' + self['id']).to_s
|
165
|
+
end
|
166
|
+
|
167
|
+
def auth_verify?(pwd_hash)
|
168
|
+
pwd = BCrypt::Password.new(pwd_hash)
|
169
|
+
pwd == self['username'] + ':' + self['password'] + '@' + self['id']
|
170
|
+
end
|
171
|
+
|
172
|
+
def id_number
|
173
|
+
OpenSSL::Digest::SHA1.new(self['id']).hexdigest
|
24
174
|
end
|
25
175
|
|
26
176
|
def [](key)
|
@@ -28,6 +178,7 @@ class NOMS::Command::Auth::Identity < NOMS::Command::Base
|
|
28
178
|
end
|
29
179
|
|
30
180
|
def []=(key, value)
|
181
|
+
$stderr.puts "auth identity set #{key} = #{value}"
|
31
182
|
@data[key] = value
|
32
183
|
end
|
33
184
|
|
@@ -39,8 +190,57 @@ class NOMS::Command::Auth::Identity < NOMS::Command::Base
|
|
39
190
|
@data.keys
|
40
191
|
end
|
41
192
|
|
42
|
-
def
|
43
|
-
|
193
|
+
def refresh_vault_key
|
194
|
+
vault_keyfile = NOMS::Command::Auth::Identity.vault_keyfile
|
195
|
+
if File.exist? vault_keyfile
|
196
|
+
File.utime(Time.now, Time.now, vault_keyfile)
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def save(opt={})
|
201
|
+
return self.specified if self.specified
|
202
|
+
begin
|
203
|
+
opt[:encrypt] = true unless opt.has_key? :encrypt
|
204
|
+
file = opt[:file] || File.join(@@identity_dir, self.id_number + '.' + (opt[:encrypt] ? 'enc' : 'json'))
|
205
|
+
data = opt[:encrypt] ? self.encrypt : (self.to_json + "\n")
|
206
|
+
|
207
|
+
File.open(file, 'w') { |fh| fh.write data }
|
208
|
+
file
|
209
|
+
rescue StandardError => e
|
210
|
+
@log.warn "Couldn't save identity for #{@data['id']} (#{e.class}): #{e.message}"
|
211
|
+
@log.debug { e.backtrace.join("\n") }
|
212
|
+
return nil
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
def clear(opt={})
|
217
|
+
@log.debug "Clearing #{@data['id']}"
|
218
|
+
begin
|
219
|
+
basefile = File.join(@@identity_dir, self.id_number)
|
220
|
+
File.unlink "#{basefile}.json" if File.exist? "#{basefile}.json"
|
221
|
+
File.unlink "#{basefile}.enc" if File.exist? "#{basefile}.enc"
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
def to_json
|
226
|
+
@data.to_json
|
227
|
+
end
|
228
|
+
|
229
|
+
def encrypt
|
230
|
+
mac_key, enc_key = NOMS::Command::Auth::Identity.get_vault_key.unpack('a32a32')
|
231
|
+
cipher = OpenSSL::Cipher.new @@cipher
|
232
|
+
cipher.encrypt
|
233
|
+
iv = cipher.random_iv
|
234
|
+
cipher.key = enc_key
|
235
|
+
plaintext = self.to_json
|
236
|
+
data = cipher.update(plaintext) + cipher.final
|
237
|
+
|
238
|
+
hmac = OpenSSL::HMAC.new(mac_key, OpenSSL::Digest.new(@@hmac_digest))
|
239
|
+
hmac.update(iv + data)
|
240
|
+
|
241
|
+
message = hmac.digest + iv + data
|
242
|
+
|
243
|
+
Base64.encode64(message)
|
44
244
|
end
|
45
245
|
|
46
246
|
def id
|
@@ -56,7 +256,7 @@ class NOMS::Command::Auth::Identity < NOMS::Command::Base
|
|
56
256
|
end
|
57
257
|
|
58
258
|
def to_s
|
59
|
-
|
259
|
+
@data['id']
|
60
260
|
end
|
61
261
|
|
62
262
|
end
|