macadmin 0.0.4

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.
@@ -0,0 +1,297 @@
1
+ module MacAdmin
2
+
3
+ # Custom error
4
+ class ShadowHashError < StandardError
5
+ UNSUPPORTED_OBJECT_ERR = 'Unsupported object: cannot store ShadowHashData'
6
+ end
7
+
8
+ # ShadowHash (super class)
9
+ # - common methods for password sub-classes
10
+ class ShadowHash
11
+
12
+ include MacAdmin::Password
13
+
14
+ attr_reader :label
15
+
16
+ class << self
17
+
18
+ # Reads the password data from the user record
19
+ # - returns an appropriate ShadowHash object
20
+ def create_from_user_record(user)
21
+ if user['ShadowHashData']
22
+ password = read_shadowhashdata(user['ShadowHashData'])
23
+ if password[SaltedSHA512::LABEL]
24
+ return SaltedSHA512.create_from_shadowhashdata(password)
25
+ else
26
+ return SaltedSHA512PBKDF2.create_from_shadowhashdata(password)
27
+ end
28
+ else
29
+ if guid = user['generateduid']
30
+ return SaltedSHA1.create_from_shadowhash_file(guid)
31
+ end
32
+ end
33
+ nil
34
+ end
35
+
36
+ # Returns Hash
37
+ # - key: label, value: password data
38
+ def read_shadowhashdata(data)
39
+ plist = CFPropertyList::List.new(:data => data[0].to_s)
40
+ CFPropertyList.native_types(plist.value)
41
+ end
42
+
43
+ end # end self
44
+
45
+ end
46
+
47
+ # Legacy ShadowHashs
48
+ # - password management for Mac OS X 10.6 and below
49
+ # - passwords are managed in separate files in /var/db/shadow/hash
50
+ class SaltedSHA1 < ShadowHash
51
+
52
+ SHADOWHASH_STORE = '/private/var/db/shadow/hash'
53
+
54
+ attr_accessor :hash
55
+
56
+ class << self
57
+
58
+ def create_from_shadowhash_file(guid)
59
+ file = "#{SHADOWHASH_STORE}/#{guid}"
60
+ if File.exists? file
61
+ hash = read_from_shadowhash_file(file)
62
+ return nil unless hash
63
+ self.new(hash)
64
+ end
65
+ end
66
+
67
+ def read_from_shadowhash_file(file)
68
+ content = File.readlines(file).first
69
+ content[168,48]
70
+ end
71
+
72
+ end
73
+
74
+ # Initializes a SaltedSHA512 ShadowHash object from string
75
+ # - string param should be a hex string, 24 bytes
76
+ def initialize(string)
77
+ @hash = validate(string)
78
+ end
79
+
80
+ # Validates the string param
81
+ # - ensure the string param is hex string 24 bytes long
82
+ def validate(string)
83
+ error = "Invalid: arg must be hexadecimal string (24 bytes)"
84
+ raise ArgumentError.new(error) unless string =~ /([A-F0-9]{2}){24}/
85
+ string
86
+ end
87
+
88
+ # Return a String representation of the ShadowHash data
89
+ def password
90
+ @hash.to_s
91
+ end
92
+
93
+ # Return the ShadowHash as a Salted SHA1 String
94
+ def data
95
+ @data ||= @hash.to_s
96
+ end
97
+
98
+ private
99
+
100
+ # Pseudo callback
101
+ # - this method does not modify User objects
102
+ # - SHA1 passwords are not stored as part of the User object
103
+ # - They're stored in separate files on disk
104
+ def store(sender)
105
+ raise ShadowHashError.new(ShadowHashError::UNSUPPORTED_OBJECT_ERR) unless sender.is_a? MacAdmin::User
106
+ @data = @hash.to_s
107
+ end
108
+
109
+ # Write password to file
110
+ # - success based on the length of the file, must be 1240 bytes
111
+ # - returns boolean
112
+ def write_to_shadowhash_file(sender)
113
+ raise ShadowHashError.new(ShadowHashError::UNSUPPORTED_OBJECT_ERR) unless sender.is_a? MacAdmin::User
114
+ path = "#{SHADOWHASH_STORE}/#{sender[:generateduid].first}"
115
+ file = File.open(path, mode='w+')
116
+ content = file.read
117
+ content = "0" * 1240 if content.length < 1240
118
+ file.rewind
119
+ content[168...(168 + 48)] = @hash.to_s
120
+ file.write content
121
+ file.close
122
+ File.size(path) == 1240
123
+ end
124
+ alias :to_file :write_to_shadowhash_file
125
+
126
+ # Remove the ShadowHash file associated with the sender
127
+ # - returns boolean
128
+ def remove_shadowhash_file(sender)
129
+ raise ShadowHashError.new(ShadowHashError::UNSUPPORTED_OBJECT_ERR) unless sender.is_a? MacAdmin::User
130
+ path = "#{SHADOWHASH_STORE}/#{sender[:generateduid].first}"
131
+ FileUtils.rm path if File.exists? path
132
+ !File.exists? path
133
+ end
134
+ alias :rm_file :remove_shadowhash_file
135
+
136
+ end
137
+
138
+ # Lion ShadowHashs
139
+ # - Mac OS X 10.7 store passwords as Salted SHA512 hashes
140
+ # - hash is stored directly in the user's plist
141
+ class SaltedSHA512 < ShadowHash
142
+
143
+ LABEL = 'SALTED-SHA512'
144
+
145
+ attr_accessor :hash
146
+
147
+ class << self
148
+
149
+ # Constructs a SaltedSHA512 ShadowHash object from ShadowHashData
150
+ # - param is raw ShadowHashData object
151
+ def create_from_shadowhashdata(data)
152
+ value = data[SaltedSHA512::LABEL].to_s
153
+ hex = MacAdmin::Password.convert_to_hex(value)
154
+ self.new(hex)
155
+ end
156
+
157
+ end
158
+
159
+ # Initializes a SaltedSHA512 ShadowHash object from string
160
+ # - string param should be a hex string, 68 bytes
161
+ def initialize(string)
162
+ @label = LABEL
163
+ @hash = validate(string)
164
+ end
165
+
166
+ # Validates the string param
167
+ # - ensure the string param is hex string 68 bytes long
168
+ def validate(string)
169
+ error = "Invalid: arg must be hexadecimal string (68 bytes)"
170
+ raise ArgumentError.new(error) unless string =~ /([a-f0-9]{2}){68}/
171
+ string
172
+ end
173
+
174
+ # Return the ShadowHash as a ShadowHashData object
175
+ # - Binary Plist
176
+ def data
177
+ @data ||= { @label => convert_to_blob(@hash) }.to_plist
178
+ end
179
+
180
+ # Return a Hash representation of the ShadowHash data
181
+ def password
182
+ { @label => @hash }
183
+ end
184
+
185
+ private
186
+
187
+ # Pseudo callback for inserting a ShadowHashData object into the User object
188
+ def store(sender)
189
+ raise ShadowHashError.new(ShadowHashError::UNSUPPORTED_OBJECT_ERR) unless sender.is_a? MacAdmin::User
190
+ @data = { @label => convert_to_blob(@hash) }.to_plist
191
+ sender['ShadowHashData'] = [@data]
192
+ end
193
+
194
+ end
195
+
196
+ # Current ShadowHash Scheme
197
+ # - Mac OS X 10.8 and up store passwords as Salted SHA512-PBKDF2 hashes
198
+ # - hash is stored directly in the user's plist
199
+ class SaltedSHA512PBKDF2 < ShadowHash
200
+
201
+ LABEL = 'SALTED-SHA512-PBKDF2'
202
+
203
+ class << self
204
+
205
+ # Constructs a SaltedSHA512PBKDF2 ShadowHash object from ShadowHashData
206
+ # - param is raw ShadowHashData object
207
+ def create_from_shadowhashdata(data)
208
+ hash = data[SaltedSHA512PBKDF2::LABEL]
209
+ hash = hash.inject({}) do |memo, (key, value)|
210
+ if key.eql? 'iterations'
211
+ value = value.to_i
212
+ else
213
+ value = MacAdmin::Password.convert_to_hex(value)
214
+ end
215
+ memo[key.to_sym] = value
216
+ memo
217
+ end
218
+ self.new(hash)
219
+ end
220
+
221
+ end
222
+
223
+ # Initializes a SaltedSHA512PBKDF2 ShadowHash object from Hash or Array
224
+ # - if passing an array, you must order the elements: entropy, salt, iterations
225
+ # - pass a Hash with keys: entropy, salt, iterations
226
+ def initialize(args)
227
+ @label = LABEL
228
+ @hash = validate(args)
229
+ end
230
+
231
+ # Validates the params
232
+ # - ensure that we have the required params
233
+ # - an Array that maps to required_keys structure
234
+ # - a Hash that contains the required keys
235
+ # - all values are qualified according to requirements
236
+ def validate(args)
237
+ error = nil
238
+ hash = nil
239
+ required_keys = [:entropy, :salt, :iterations]
240
+ if args.is_a? Array
241
+ hash = Hash[required_keys.zip(args)]
242
+ elsif args.is_a? Hash
243
+ hash = args
244
+ end
245
+ # validate hash
246
+ unless (hash.keys - required_keys).empty?
247
+ error = "Invalid: args must contain, #{required_keys.join(', ')}"
248
+ end
249
+ unless hash[:entropy] =~ /([a-f0-9]{2}){128}/
250
+ error = "Invalid: entropy must be hexadecimal string (128 bytes)"
251
+ end
252
+ unless hash[:salt] =~ /([a-f0-9]{2}){32}/
253
+ error = "Invalid: salt must be hexadecimal string (32 bytes)"
254
+ end
255
+ unless hash[:iterations] >= 0
256
+ error = "Invalid: entropy must positive integer"
257
+ end
258
+ raise ArgumentError.new(error) if error
259
+ hash
260
+ end
261
+
262
+ # Return the ShadowHash as a ShadowHashData object
263
+ # - Binary Plist
264
+ def data
265
+ @data ||= { @label => format(@hash) }.to_plist
266
+ end
267
+
268
+ # Return a Hash representation of the ShadowHash data
269
+ def password
270
+ { @label => @hash }
271
+ end
272
+
273
+ private
274
+
275
+ # Pseudo callback for inserting a ShadowHashData object into the User object
276
+ def store(sender)
277
+ raise ShadowHashError.new(ShadowHashError::UNSUPPORTED_OBJECT_ERR) unless sender.is_a? MacAdmin::User
278
+ @data = { @label => format(@hash) }.to_plist
279
+ sender['ShadowHashData'] = [@data]
280
+ end
281
+
282
+ # Format the password data for ShadowHashData object compatibility
283
+ def format(hash)
284
+ hash.inject({}) do |memo, (key, value)|
285
+ if key.to_s.eql? 'iterations'
286
+ value = value.to_i
287
+ else
288
+ value = convert_to_blob value
289
+ end
290
+ memo[key.to_s] = value
291
+ memo
292
+ end
293
+ end
294
+
295
+ end
296
+
297
+ end
@@ -0,0 +1,8 @@
1
+ module MacAdmin
2
+ VERSION = '0.0.4'
3
+
4
+ def self.version_string
5
+ "macadmin version, #{MacAdmin::VERSION}"
6
+ end
7
+
8
+ end
@@ -0,0 +1,35 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'macadmin/version'
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = "macadmin"
8
+ s.version = MacAdmin::VERSION
9
+ s.date = '2012-07-08'
10
+ s.authors = ["Brian Warsing"]
11
+ s.email = ['dayglojesus@gmail.com']
12
+ s.description = "Gem to assist in performing common systems administration tasks in Mac OS X"
13
+ s.summary = "Ruby Mac Systems Administration Library"
14
+ s.homepage = "http://github.com/dayglojesus/macadmin"
15
+ s.license = "MIT"
16
+ s.files = `git ls-files`.split($/)
17
+ s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
19
+ s.require_paths = ["lib", "ext"]
20
+ s.extensions = Dir['ext/**/extconf.rb']
21
+
22
+ s.platform = Gem::Platform::RUBY
23
+
24
+ s.add_development_dependency "bundler", "~> 1.3"
25
+ s.add_development_dependency "rake"
26
+ s.add_development_dependency "rake-compiler"
27
+ s.add_development_dependency "rspec"
28
+ s.add_development_dependency "CFPropertyList"
29
+
30
+ s.add_runtime_dependency "bundler", "~> 1.3"
31
+ s.add_runtime_dependency "rake"
32
+ s.add_runtime_dependency "rake-compiler"
33
+ s.add_runtime_dependency "CFPropertyList"
34
+
35
+ end
@@ -0,0 +1,49 @@
1
+ require 'spec_helper'
2
+
3
+ describe MacAdmin::Common do
4
+
5
+ describe 'MAC_OS_X_PRODUCT_VERSION' do
6
+ it 'is a float greater than 10' do
7
+ version = MacAdmin::Common::MAC_OS_X_PRODUCT_VERSION
8
+ version.should > 10
9
+ end
10
+ end
11
+
12
+ describe '#load_plist' do
13
+ it 'should return an Hash object' do
14
+ file = '/System/Library/CoreServices/SystemVersion.plist'
15
+ MacAdmin::Common::load_plist(file).should be_an_instance_of Hash
16
+ end
17
+ end
18
+
19
+ end
20
+
21
+ describe MacAdmin::Common::UUID do
22
+
23
+ before :all do
24
+ @uuid = '897A6343-628F-4964-80F1-C86D0FFA3F91'
25
+ end
26
+
27
+ describe '#new' do
28
+ it 'should return a UUID string' do
29
+ UUID.new.should =~ /([A-Z0-9]{8})-([A-Z0-9]{4}-){3}([A-Z0-9]{12})/
30
+ end
31
+ end
32
+
33
+ describe '#match' do
34
+ it 'should match and return a UUID in _any_ string' do
35
+ UUID.match("com.apple.loginwindow.#{@uuid}.plist").should == @uuid
36
+ end
37
+ end
38
+
39
+ describe '#valid?' do
40
+ it 'should return true if handed a valid UUID string' do
41
+ UUID.valid?(@uuid).should == true
42
+ end
43
+
44
+ it 'should return false if handed a _bad_ UUID string' do
45
+ UUID.valid?(@uuid.chop).should == false
46
+ end
47
+ end
48
+
49
+ end
@@ -0,0 +1,133 @@
1
+ require 'spec_helper'
2
+ require 'fileutils'
3
+
4
+ describe MacAdmin::Computer do
5
+
6
+ before :all do
7
+ # Create a dslocal sandbox
8
+ @test_dir = "/private/tmp/macadmin_computer_test.#{rand(100000)}"
9
+ MacAdmin::DSLocalRecord.send(:remove_const, :DSLOCAL_ROOT)
10
+ MacAdmin::DSLocalRecord::DSLOCAL_ROOT = File.expand_path @test_dir
11
+ FileUtils.mkdir_p "#{@test_dir}/Default/computers"
12
+
13
+ # Create two computer record plists that we can load
14
+ planet_express = {
15
+ "en_address" => ["aa:aa:aa:aa:aa:aa"],
16
+ "realname" => ["Planet Express"],
17
+ "name" => ["planet-express"],
18
+ "generateduid" => ["00000000-0000-0000-0000-000000000001"],
19
+ }
20
+
21
+ [planet_express].each do |computer|
22
+ record = CFPropertyList::List.new
23
+ record.value = CFPropertyList.guess(computer)
24
+ record.save("#{@test_dir}/Default/computers/#{computer['name'].first}.plist", CFPropertyList::List::FORMAT_BINARY)
25
+ end
26
+
27
+ end
28
+
29
+ describe '#new' do
30
+
31
+ context 'throws an ArgumentError when given fewer than 1 params' do
32
+ subject { Computer.new }
33
+ it { expect { subject }.to raise_error(ArgumentError) }
34
+ end
35
+
36
+ context 'when created from a parameter Hash' do
37
+ subject { Computer.new :name => 'nimbus', :en_address => 'dd:dd:dd:dd:dd:dd' }
38
+ it { should be_an_instance_of Computer }
39
+ it 'should have a valid generateduid attribute' do
40
+ (UUID.valid?(subject.generateduid.first)).should be_true
41
+ end
42
+ its (:name) { should eq ['nimbus'] }
43
+ its (:realname) { should eq ['nimbus'.capitalize] }
44
+ its (:en_address) { should eq ['dd:dd:dd:dd:dd:dd'] }
45
+ end
46
+
47
+ context 'when created from a just a name (String)' do
48
+ subject { Computer.new 'nimbus' }
49
+ it { should be_an_instance_of Computer }
50
+ it 'should have a valid generateduid attribute' do
51
+ (UUID.valid?(subject.generateduid.first)).should be_true
52
+ end
53
+ its (:name) { should eq ['nimbus'] }
54
+ its (:realname) { should eq ['nimbus'.capitalize] }
55
+ its (:en_address) { should eq [MacAdmin::Common.get_primary_mac_address] }
56
+ end
57
+
58
+ end
59
+
60
+ describe '#exists?' do
61
+
62
+ context "when the object is different from its associated file" do
63
+ subject { Computer.new :name => 'planet-express', :en_address => "ff:ff:ff:ff:ff:ff" }
64
+ it 'has an associated file' do
65
+ File.exists?(subject.file).should be_true
66
+ end
67
+ it 'returns false because file and object do not match' do
68
+ subject.exists?.should be_false
69
+ end
70
+ end
71
+
72
+ context "when ALL the object's attributes match its associated file" do
73
+ subject { Computer.new :name => 'planet-express', :en_address => "aa:aa:aa:aa:aa:aa" }
74
+ it 'has an associated file' do
75
+ File.exists?(subject.file).should be_true
76
+ end
77
+ it 'returns false because file and object do not match' do
78
+ subject.exists?.should be_true
79
+ end
80
+ end
81
+
82
+ context "when the object does not have an associated file on disk" do
83
+ subject { Computer.new :name => 'nimbus' }
84
+ it 'does not have an associated file' do
85
+ File.exists?(subject.file).should be_false
86
+ end
87
+ it 'should return false' do
88
+ subject.exists?.should be_false
89
+ end
90
+ end
91
+
92
+ end
93
+
94
+ describe '#create' do
95
+
96
+ context "with NO path parameter (default)" do
97
+ subject { Computer.new :name => 'nimbus', :en_address => "bb:bb:bb:bb:bb:bb" }
98
+ it 'saves the record to disk on the derived path' do
99
+ subject.create.should be_true
100
+ File.exists?(subject.file).should be_true
101
+ end
102
+ after do
103
+ FileUtils.rm_rf subject.file
104
+ end
105
+ end
106
+
107
+ context "with a path parameter" do
108
+ path = "/private/tmp/group-create-method-test.plist"
109
+ subject { Computer.new :name => 'nimbus', :en_address => "bb:bb:bb:bb:bb:bb" }
110
+ it 'saves the record to disk on the path specified' do
111
+ subject.create(path).should be_true
112
+ end
113
+ after do
114
+ FileUtils.rm_rf path
115
+ end
116
+ end
117
+
118
+ end
119
+
120
+ describe '#destroy' do
121
+ subject { Computer.new :name => 'nimbus', :en_address => "bb:bb:bb:bb:bb:bb" }
122
+ it 'removes the record on disk and returns true' do
123
+ subject.create
124
+ subject.destroy.should be_true
125
+ File.exists?(subject.file).should be_false
126
+ end
127
+ end
128
+
129
+ after :all do
130
+ FileUtils.rm_rf @test_dir
131
+ end
132
+
133
+ end