mobile-secrets 0.0.9 → 0.1.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f4f08928281f7ec09b0f307fde69ac6a240fc006cdb398741b94b94906952f6
4
- data.tar.gz: 2b28b5fccdd4b442bf229733eaad00e170baee9e4c5505a925761f947929bda8
3
+ metadata.gz: 6bfbc5bc8fa622ef393019dc29b77d0067e329ed7dffb68f966ed659265dc221
4
+ data.tar.gz: 199b3c9c305fc0de836e589dddd36fdd694362a2cff6b2b7ac55719c6f044f7d
5
5
  SHA512:
6
- metadata.gz: 2f7805f87c8cf0713394b29e4f40da1654661ea736640de9fb59f95b8aff7e2928b5941a67f993af709c3b31cb62f4d1f1fbcbca88bd856303b05ddb9966ed13
7
- data.tar.gz: 693fabe5ec237be1fcaf50ca841b60df85dccde3d017bf97e059cffe8ad6b216e8656be7ebef97688fb6e25318e93bfd36cbc09b2752fbd69ba42d39be1f72a3
6
+ metadata.gz: c48011bde102a1994bd6e4766bc8aef593c33a3fc84448d69727987802272de54165cc9e7059a00c711aaf0c0274372a7a79cca7005e218d5dd3192e8ca2f44d
7
+ data.tar.gz: e67c2c5e894ce0a29487957b976e3f30f023939302a1074ca3cc289ae50df18a77b13d1d1ae5dc7885973a47311b3585bc3ec6b221c0368d46737c97a850c729
@@ -74,7 +74,7 @@ module MobileSecrets
74
74
  MobileSecrets::SourceRenderer.new("swift").render_empty_template "#{file_path}/secrets.swift"
75
75
  when "--edit"
76
76
  return print_options if argv_1 == nil
77
- exec("dotgpg edit #{argv_1}")
77
+ exec("dotgpg", "edit", argv_1)
78
78
  when "--usage"
79
79
  puts usage
80
80
  else
@@ -3,16 +3,17 @@
3
3
  //
4
4
 
5
5
  <% if should_decrypt_files %>import CommonCrypto<% end %>
6
+ <% if algorithm == "AES-GCM" %>import CryptoKit<% end %>
6
7
  import Foundation
7
8
 
8
9
  // swiftlint:disable all
9
- class Secrets {
10
+ <% if algorithm == "AES-GCM" %>@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
11
+ <% end %>final class Secrets: Sendable {
10
12
  static let standard = Secrets()
11
13
  private let bytes: [[UInt8]] = <%= secrets_array.to_s.gsub "],", "],\n "%>
12
14
  <% if should_decrypt_files %>
13
15
  private let fileNames: [[UInt8]] = <%= file_names_array.to_s.gsub "],", "],\n "%>
14
16
  <% end %>
15
-
16
17
  private init() {}
17
18
 
18
19
  func string(forKey key: String, password: String? = nil) -> String? {
@@ -24,6 +25,27 @@ class Secrets {
24
25
  return String(data: Data(value), encoding: .utf8)
25
26
  }
26
27
 
28
+ <% if algorithm == "AES-GCM" %>
29
+ // AES-256-GCM decryption. Input layout: IV(12) + AuthTag(16) + Ciphertext(N)
30
+ private func decrypt(_ input: [UInt8], password: [UInt8]) -> [UInt8]? {
31
+ guard password.count == 32 else { return nil }
32
+ let ivSize = 12
33
+ let tagSize = 16
34
+ guard input.count > ivSize + tagSize else { return nil }
35
+ do {
36
+ let key = SymmetricKey(data: Data(password))
37
+ let nonce = try AES.GCM.Nonce(data: Data(input[0..<ivSize]))
38
+ let tag = Data(input[ivSize..<(ivSize + tagSize)])
39
+ let ciphertext = Data(input[(ivSize + tagSize)...])
40
+ let sealedBox = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag)
41
+ let decrypted = try AES.GCM.open(sealedBox, using: key)
42
+ return [UInt8](decrypted)
43
+ } catch {
44
+ return nil
45
+ }
46
+ }
47
+ <% else %>
48
+ // XOR-based deobfuscation. The key cycles over the password bytes.
27
49
  private func decrypt(_ input: [UInt8], password: [UInt8]) -> [UInt8]? {
28
50
  guard !password.isEmpty else { return nil }
29
51
  var output = [UInt8]()
@@ -32,7 +54,7 @@ class Secrets {
32
54
  }
33
55
  return output
34
56
  }
35
-
57
+ <% end %>
36
58
  <% if should_decrypt_files %>
37
59
  func decryptFiles(bundle: Bundle = Bundle.main, password: String? = nil) throws {
38
60
  try fileNames.forEach({ (fileNameBytes) in
@@ -61,7 +83,7 @@ class Secrets {
61
83
  outputURL.appendPathComponent(fileName)
62
84
 
63
85
  do {
64
- let aes = try AES(keyString: pwd)
86
+ let aes = try AESFileCipher(keyString: pwd)
65
87
  let decryptedString = try aes.decrypt(fileData)
66
88
  try decryptedString.write(to: outputURL, atomically: true, encoding: .utf8)
67
89
  } catch let e {
@@ -69,7 +91,8 @@ class Secrets {
69
91
  }
70
92
  }
71
93
 
72
- struct AES {
94
+ // AES-256-CBC cipher used for decrypting .enc file resources.
95
+ private struct AESFileCipher {
73
96
  enum Error: Swift.Error {
74
97
  case invalidKeySize
75
98
  case encryptionFailed
@@ -79,7 +102,7 @@ class Secrets {
79
102
 
80
103
  private var key: Data
81
104
  private var ivSize: Int = kCCBlockSizeAES128
82
- private let options: CCOptions = CCOptions()
105
+ private let options: CCOptions = CCOptions(kCCOptionPKCS7Padding)
83
106
 
84
107
  init(keyString: String) throws {
85
108
  guard keyString.count == kCCKeySizeAES256 else {
@@ -104,10 +127,10 @@ class Secrets {
104
127
  throw Error.encryptionFailed
105
128
  }
106
129
 
107
- let cryptStatus: CCCryptorStatus = CCCrypt( // Stateless, one-shot encrypt operation
130
+ let cryptStatus: CCCryptorStatus = CCCrypt( // Stateless, one-shot decrypt operation
108
131
  CCOperation(kCCDecrypt), // op: CCOperation
109
- CCAlgorithm(kCCAlgorithmAES), // alg: CCAlgorithm
110
- options, // options: CCOptions
132
+ CCAlgorithm(kCCAlgorithmAES), // alg: CCAlgorithm
133
+ options, // options: CCOptions (PKCS7 padding)
111
134
  keyBytesBaseAddress, // key: the "password"
112
135
  key.count, // keyLength: the "password" size
113
136
  dataToDecryptBytesBaseAddress, // iv: Initialization Vector
@@ -135,6 +158,6 @@ class Secrets {
135
158
 
136
159
  return decryptedString
137
160
  }
138
- }
161
+ }
139
162
  <% end %>
140
163
  }
@@ -1,17 +1,22 @@
1
1
  MobileSecrets:
2
2
  # hashKey: Key that will be used to hash the secret values.
3
3
  # For encrypting files the key needs to be 32 chars long as an AES standard.
4
- hashKey: "KokoBelloKokoKokoBelloKokoKokoBe"
4
+ # When using alg: "AES-GCM" the key must also be exactly 32 characters.
5
+ hashKey: "REPLACE_THIS_32_CHAR_HASH_KEY___"
5
6
  # shouldIncludePassword: By default the password is saved in the code as a series of bytes, however it can also
6
7
  # be fetched from your API, saved in keychain and passed to the Secrets for improving the security.
7
8
  shouldIncludePassword: true
8
9
  # language: Swift is currently only supported language, Kotlin is coming soon.
9
10
  language: "Swift"
11
+ # alg: The algorithm used to obfuscate secret values. Options: "XOR" (default) or "AES-GCM".
12
+ # AES-GCM provides authenticated encryption and is stronger than XOR obfuscation.
13
+ # Note: AES-GCM requires hashKey to be exactly 32 characters and iOS 13+ / macOS 10.15+.
14
+ alg: "XOR"
10
15
  # Key-value dictionary for secrets. The key is then referenced in the code to get the secret.
11
16
  secrets:
12
- googleMaps: "123123123"
13
- firebase: "asdasdasd"
14
- amazon: "asd123asd123"
17
+ googleMaps: "YOUR_GOOGLE_MAPS_KEY"
18
+ firebase: "YOUR_FIREBASE_KEY"
19
+ amazon: "YOUR_AMAZON_KEY"
15
20
  # Optional, remove files if you do not want to encrypt them
16
21
  files:
17
22
  - tmp.txt
@@ -1,5 +1,7 @@
1
1
  require "dotgpg"
2
2
  require "yaml"
3
+ require "openssl"
4
+ require "stringio"
3
5
 
4
6
  require_relative '../src/obfuscator'
5
7
  require_relative '../src/file_handler'
@@ -8,39 +10,52 @@ require_relative '../src/source_renderer'
8
10
  module MobileSecrets
9
11
  class SecretsHandler
10
12
 
13
+ SUPPORTED_ALGORITHMS = %w[XOR AES-GCM].freeze
14
+
11
15
  def export_secrets path, from_encrypted_file_name
12
16
  decrypted_config = decrypt_secrets(from_encrypted_file_name)
13
- file_names_bytes, secrets_bytes = process_yaml_config decrypted_config
17
+ file_names_bytes, secrets_bytes, algorithm = process_yaml_config decrypted_config
14
18
 
15
19
  renderer = MobileSecrets::SourceRenderer.new "swift"
16
- renderer.render_template secrets_bytes, file_names_bytes, "#{path}/secrets.swift"
20
+ renderer.render_template secrets_bytes, file_names_bytes, "#{path}/secrets.swift", algorithm
17
21
  decrypted_config
18
22
  end
19
23
 
20
24
  def process_yaml_config yaml_string
21
- config = YAML.load(yaml_string)["MobileSecrets"]
25
+ config = YAML.safe_load(yaml_string)["MobileSecrets"]
22
26
  hash_key = config["hashKey"]
23
27
  secrets_dict = config["secrets"]
24
28
  files = config["files"]
25
29
  should_include_password = config["shouldIncludePassword"]
30
+ algorithm = (config["alg"] || "XOR").upcase
31
+
32
+ abort("Unsupported algorithm '#{algorithm}'. Valid options: #{SUPPORTED_ALGORITHMS.join(', ')}.") \
33
+ unless SUPPORTED_ALGORITHMS.include?(algorithm)
34
+ abort("hashKey must be exactly 32 characters for AES-GCM encryption.") \
35
+ if algorithm == "AES-GCM" && hash_key.length != 32
36
+
26
37
  secrets_bytes = should_include_password ? [hash_key.bytes] : []
27
38
  file_names_bytes = []
28
39
  obfuscator = MobileSecrets::Obfuscator.new hash_key
29
40
 
30
41
  secrets_dict.each do |key, value|
31
- encrypted = obfuscator.obfuscate(value)
32
- secrets_bytes << key.bytes << encrypted.bytes
42
+ if algorithm == "AES-GCM"
43
+ secrets_bytes << key.bytes << encrypt_aes_gcm(value.to_s, hash_key, key)
44
+ else
45
+ encrypted = obfuscator.obfuscate(value.to_s)
46
+ secrets_bytes << key.bytes << encrypted.bytes
47
+ end
33
48
  end
34
49
 
35
50
  if files
36
- abort("Password must be 32 characters long for files encryption.") if hash_key.length != 32
51
+ abort("hashKey must be 32 characters long for files encryption.") if hash_key.length != 32
37
52
  files.each do |f|
38
53
  encrypt_file hash_key, f, "#{f}.enc"
39
54
  file_names_bytes << f.bytes
40
55
  end
41
56
  end
42
57
 
43
- return file_names_bytes, secrets_bytes
58
+ return file_names_bytes, secrets_bytes, algorithm
44
59
  end
45
60
 
46
61
  def encrypt output_file_path, string, gpg_path
@@ -60,6 +75,24 @@ module MobileSecrets
60
75
 
61
76
  private
62
77
 
78
+ # Encrypts a secret value with AES-256-GCM using a deterministic IV.
79
+ # The IV is derived via HMAC-SHA256(key, "secret_name:value") truncated to 12 bytes,
80
+ # so identical inputs always produce identical ciphertext while ensuring that
81
+ # changing either the value or the secret name produces a different IV — preventing
82
+ # nonce reuse, which would be catastrophic for GCM authentication.
83
+ # Returns bytes laid out as: IV(12) + AuthTag(16) + Ciphertext(N)
84
+ def encrypt_aes_gcm(value, key_string, secret_name)
85
+ iv = OpenSSL::HMAC.digest('SHA256', key_string, "#{secret_name}:#{value}")[0, 12]
86
+ cipher = OpenSSL::Cipher::AES256.new(:GCM)
87
+ cipher.encrypt
88
+ cipher.iv = iv
89
+ cipher.key = key_string
90
+ cipher.auth_data = ""
91
+ ciphertext = cipher.update(value) + cipher.final
92
+ tag = cipher.auth_tag(16)
93
+ (iv + tag + ciphertext).bytes
94
+ end
95
+
63
96
  def decrypt_secrets encrypted_file_name
64
97
  gpg = Dotgpg::Dir.closest encrypted_file_name
65
98
  output = StringIO.new
@@ -8,7 +8,7 @@ module MobileSecrets
8
8
  @source_type = source_type.downcase
9
9
  end
10
10
 
11
- def render_template secrets_bytes, file_names_bytes, output_file_path
11
+ def render_template secrets_bytes, file_names_bytes, output_file_path, algorithm = "XOR"
12
12
  template = ERB.new(File.read("#{__dir__}/../resources/SecretsSwift.erb"))
13
13
 
14
14
  case @source_type
@@ -16,7 +16,8 @@ module MobileSecrets
16
16
  File.open(output_file_path, "w") do |file|
17
17
  file.puts template.result_with_hash(secrets_array: secrets_bytes,
18
18
  file_names_array: file_names_bytes,
19
- should_decrypt_files: file_names_bytes.length > 0)
19
+ should_decrypt_files: file_names_bytes.length > 0,
20
+ algorithm: algorithm)
20
21
  end
21
22
  end
22
23
  end
metadata CHANGED
@@ -1,12 +1,12 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mobile-secrets
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.9
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Cermak
8
8
  - Joerg Nestele
9
- autorequire:
9
+ autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
12
  date: 2019-09-27 00:00:00.000000000 Z
@@ -25,6 +25,20 @@ dependencies:
25
25
  - - '='
26
26
  - !ruby/object:Gem::Version
27
27
  version: 0.7.0
28
+ - !ruby/object:Gem::Dependency
29
+ name: minitest-reporters
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: '1.8'
35
+ type: :development
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: '1.8'
28
42
  description: Handle mobile secrets the secure way with ease
29
43
  email: cyril.cermakk@gmail.com
30
44
  executables:
@@ -46,7 +60,7 @@ homepage: https://github.com/CyrilCermak/mobile-secrets
46
60
  licenses:
47
61
  - MIT
48
62
  metadata: {}
49
- post_install_message:
63
+ post_install_message:
50
64
  rdoc_options: []
51
65
  require_paths:
52
66
  - lib
@@ -61,8 +75,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
61
75
  - !ruby/object:Gem::Version
62
76
  version: '0'
63
77
  requirements: []
64
- rubygems_version: 3.0.8
65
- signing_key:
78
+ rubygems_version: 3.4.10
79
+ signing_key:
66
80
  specification_version: 4
67
81
  summary: mobile-secrets tool for handling your mobile secrets
68
82
  test_files: []