smaak 0.0.10 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/README.md +112 -53
- data/lib/smaak/adaptors/net_http_adaptor.rb +43 -0
- data/lib/smaak/adaptors/rack_adaptor.rb +32 -0
- data/lib/smaak/associate.rb +12 -6
- data/lib/smaak/auth_message.rb +55 -39
- data/lib/smaak/cavage_04.rb +84 -0
- data/lib/smaak/client.rb +38 -21
- data/lib/smaak/crypto.rb +45 -0
- data/lib/smaak/server.rb +33 -16
- data/lib/smaak/smaak_service.rb +29 -0
- data/lib/smaak/utils.rb +10 -0
- data/lib/smaak/version.rb +1 -1
- data/lib/smaak.rb +58 -18
- data/smaak.gemspec +1 -0
- data/spec/lib/smaak/adaptors/net_http_adaptor_spec.rb +99 -0
- data/spec/lib/smaak/adaptors/rack_adaptor_spec.rb +83 -0
- data/spec/lib/smaak/auth_message_spec.rb +78 -147
- data/spec/lib/smaak/cavage_04_spec.rb +231 -0
- data/spec/lib/smaak/client_spec.rb +147 -34
- data/spec/lib/smaak/crypto_spec.rb +94 -0
- data/spec/lib/smaak/server_spec.rb +172 -22
- data/spec/lib/smaak/smaak_service_spec.rb +52 -0
- data/spec/lib/smaak_spec.rb +130 -52
- data/spec/spec_helper.rb +3 -0
- metadata +32 -3
- data/lib/smaak/request_signing_validator.rb +0 -20
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 41115c26166ffbbd902b43707e6e219101155802
|
4
|
+
data.tar.gz: 6cb113261fc5c764ebf137eec743297a664d709a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: eee3f15e1cb880f556fadc7338900ca6ee0a856b70cd1b0db5ee0d1f8d7d6aeb2d9d3297cc354a840b1b5397af9136a9f603a1a9cddab250572c1c11d757ec71
|
7
|
+
data.tar.gz: 193ecdd1021ff1f8f5cd485196e383af674aa02d02d4552bc5448dc86c3ef1b465d6f1aa9ef45ae1450cdca0c41a4069f1cfc50ad01cf5e26e17641e733bece7
|
data/.gitignore
CHANGED
data/README.md
CHANGED
@@ -1,8 +1,117 @@
|
|
1
1
|
# Smaak
|
2
2
|
|
3
|
-
This gems caters for both client and server sides of a signed message interaction over HTTP
|
3
|
+
This gems caters for both client and server sides of a signed message interaction over HTTP implementing RFC2617 Digest Access Authentication as well as IETF draft-cavage-http-signatures-04, extended with 'x-smaak-recipient', 'x-smaak-identifier', 'x-smaak-psk', 'x-smaak-expires' and 'x-smaak-nonce' headers. The following compromises are protected against as specified: Man in the middle (header and payload signature, as well as body digest) / snooping (message body encryption), Replay (nonce + expiry), Forgery (signature), Masquerading (identifier and signature), Forwarding / Unintended recipient (recipient pub key check), Clear-text password compromise (MD5 pre-shared key, obfuscated), lack of password (pre-shared key), Message fabrication (associations are purpose-fully provisioned to known associates.)
|
4
|
+
|
5
|
+
## Smaak mechanism:
|
6
|
+
|
7
|
+
When provisioning a Smaak::Server and a Smaak::Client, all associations these services should be aware of are provisioned by calling add_association. The associations are indexed by identifier (e.g. FQDN of the associate,) and remember the associate's public key, a pre-shared key and a boolean indicating whether the association expects data to encrypted.
|
8
|
+
|
9
|
+
Smaak appends 'x-smaak' headers to the HTTP request to convey a generated nonce, expiry, the requestor's identifier, the pre-shared key (obfuscated) and a digest of the request body. The headers are signed using the requestor (Smaak::Client)'s private key. If encryption is requested, the message body is encrypted using the receiver (Smaak::Server)'s public key. The message body for the response from the Smaak::Server to the Smaak::Client is also encrypted. RSA 4096 bit keys are recommended.
|
10
|
+
|
11
|
+
The signing of an HTTP request and the placement of the signature in an Authorization header is performed by a signing specification specified when signing. The algorithm implementing the signing specification is expected to embed a Smaak::AuthMessage in the signature and allow access to it when the signed header is interpreted. Currently IETF draft-cavage-http-signatures-04 is the only supported signature specification.
|
12
|
+
|
13
|
+
Smaak verifies an AuthMessage signed in the Authorization header by looking at nonce, expiry, recipient and pre-shared key. The order of headers signed is important for signature verification.
|
14
|
+
|
15
|
+
### Example Server:
|
16
|
+
|
17
|
+
A Smaak::Server operates on an instance of an HTTP request received. The Smaak module can be told about different request technology implementations by providing an adaptor to a request technology (Smaak::add_request_adaptor). The gem ships with a Rack::Request adaptor. Call Smaak::create_adaptor with your request to get an instance of an adaptor.
|
18
|
+
|
19
|
+
A Smaak::Server needs to keep track of nonces received in the fresh-ness interval of requests. To make this easy, you can extend Smaak::SmaakService. Override the configure_service method to provide your server's public key, private key and associations. Smaak::SmaakService provides a cache of received nonces checked against the freshness interval.
|
20
|
+
|
21
|
+
When setting up a Smaak::Server, tell the server of your SmaakService and verify incoming request, so:
|
22
|
+
|
23
|
+
class SecureServer < Smaak::SmaakService
|
24
|
+
def configure_services
|
25
|
+
@smaak_server.set_public_key(File.read '/secure/server_public.pem')
|
26
|
+
@smaak_server.set_private_key(File.read '/secure/server_private.pem') # only required when encryption is specified
|
27
|
+
@smaak_server.add_association('client-facing-service-needing-back-end-data', File.read '/secure/client_public.pem', 'client-pre-shared-key')
|
28
|
+
end
|
29
|
+
|
30
|
+
class SecureService
|
31
|
+
def serve(request)
|
32
|
+
auth_message, body = SecureServer.get_instance.smaak_server.verify_signed_request(request)
|
33
|
+
return [200, "message from #{auth_message.identifier} verified!"] if auth_message.identifier
|
34
|
+
[401, "Insufficient proof!"]
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
### Example Client:
|
39
|
+
|
40
|
+
A Smaak::Client operates on an instance of an HTTP request. The Smaak module can be told about different request technology implementations by providing an adaptor to a request technology (Smaak::add_request_adaptor). The gem ships with a Net::HTTP adaptor. Call Smaak::create_adaptor with your request to get an instance of an adaptor.
|
41
|
+
|
42
|
+
# The user requested some data which requires my service to talk to another, a Smaak::Server
|
43
|
+
class SecureController
|
44
|
+
def initialize
|
45
|
+
# We recommend this configuration be provisioned by a secure configuration service and/or by service boot-strapping
|
46
|
+
@client = Smaak::Client.new
|
47
|
+
@client.set_identifier('client-facing-service-needing-back-end-data')
|
48
|
+
@client.set_private_key(File.read '/secure/client_private.pem')
|
49
|
+
@client.add_association('service-provider', File.read('/secure/server_public.pem'), 'client-pre-shared-key', true) # encrypted
|
50
|
+
end
|
51
|
+
|
52
|
+
def serve(request)
|
53
|
+
response = @client.post('service-provider', 'http://service-provider.com:9393/backend', { 'index1' => 'data1', 'index2' => 'data2' }.to_json)
|
54
|
+
[200, response.body]
|
55
|
+
end
|
56
|
+
|
57
|
+
class SecureConsumer
|
58
|
+
def initialize
|
59
|
+
...
|
60
|
+
end
|
61
|
+
|
62
|
+
def some_other(param)
|
63
|
+
response = @client.get('service-provider', 'http://service-provider.com:9393/query', { 'looking_for' => param }.to_json)
|
64
|
+
consume_response(response)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
## Identity management
|
69
|
+
|
70
|
+
During provisioning, we recommend that the key-pair that does the signing and verification has associated with it an X.509 certificate signed by a CA you trust that contains the identity of the signer. The association is provisioned with an 'identifier' that the Authorization header transports in the 'x-smaak-identifier' header. This identifier is used on the receiver end to look up the public key of the signer in the association list. Once the associated key successfully verifies the signature, that certificate's identity can be used for identity management and authorization. This allows multiple identifiers (e.g. multiple server heads) to represent a single service (identity) with separate signing certs for each head.
|
71
|
+
|
72
|
+
## Example on-the-wire requests
|
73
|
+
|
74
|
+
### Un-encrypted
|
75
|
+
|
76
|
+
POST http://service-provider-internal:9393/secure-service HTTP/1.1
|
77
|
+
Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
78
|
+
Accept: */*
|
79
|
+
User-Agent: Ruby
|
80
|
+
Authorization: Signature keyId="rsa-key-1",algorithm="rsa-sha256", headers="host date digest x-smaak-recipient x-smaak-identifier x-smaak-psk x-smaak-expires x-smaak-nonce x-smaak-encrypt content-length", signature="DywQfsuJOzP7uQw/rZ3sEKtyBlzs+3Gqif/WEjjxjC1h6/vsMP6LHz1jeCQdBWRgPZ/NonM06NeSWei/YXpg9dtntoWWHQ8e8pvQsLVrx0BuMAyGhckuE9IcUSnaAOqCGCTcEV2cIE6a50tPSbBHS88jzIasliMrM8QIG2boIB9hMXbYNCPUzUKo7mOtda2NUrGwYflmLZ1cXuRGHeXuG/m/kYJOSSUawrneWH3uxuIhJTQiVtblYr7tHDQsAB6WJgCxLkrZJreALYyM62D6Cpvip3atMoKh+2b3/SqseSdt2BirrMiTdS+1+6Tyk+z/y9sGhEF0WZoIOZUmo1+7yfXe4hHaa7SQD8olsjoJTPaBsd39sb5xPZKsFS3k/eeWQxXEa7iLLumDgHncaIhyRkyb+XTG6/qk0XemBuc4LlU1JjFCdGYjYx0T9V9OUrt8Jpi6g2FKg8JLaJsd2Xk4sUBwHMrYwweutdCXxbHZn2VYF5BydDB+Eesc62PC8jh1xiAVperSUF60HdOLhcJp/eJz7VuyjaRx+EYVNqBHxIG9w9si/pcxy6tX2yA5Go+UJ37xG5E12P3QNDC6HBEEqq8tOW+YqnOacm+IbI2YSZ/ilEbSYhmE/KH7GZKxl1cSvswHcDrYRwFhviSPutQGBtGl6o/YjdmpYAVcZiEGUtE="
|
81
|
+
Host: service-provider-internal
|
82
|
+
Date: 2015-06-25 09:48:13 GMT
|
83
|
+
Digest: SHA-256=0190f465c943501984c4018bacdbb0be167979f261caf1fe50ce63e97d31dff2
|
84
|
+
X-Smaak-Recipient: LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUFxL2tiYjdBNWllQWV1WlBBVnI3MAo5cjl1TkFzc2dmYkdjeGMzZTc3RDNndkY4U2tzbURNQmQyTUt5TUh0ZjBrM1pqSVdZemJJVG5jQXM1Nnd4cmRSClhiVHpIZnhjMll1dDMwd0ljR2YvUVk4ZTJXNmdMWko4aVM3MXlYb0JQNFpEc2lLSXd4ajFsenYyVFlXWnNSL3EKd28xSzBxZ1NzOXJJVEVkWDVqampycHBYWTdobHNPMGVKQ2JBRG0weEtnU1hMcFQycnJzUnJ2OFllRXFvZTRMaQpDOFd6RjZZRlh1U3RHR1E4SXlxbjdPaTN5aVU2WFc3OTl2cFpIeHJlaERYaytDalZuU0ZXWkVPUHg3cENpam9SCnlXb0gyUmR6QVpQczdVdVJWOUdGWWFQeHRudmttNVdVZDVTdWVCNlMxT2E4dVZ3UnpyeXl6WkRjdG0xdWs1VjIKUE0zLzFqbFJMbFJzTWxSeHdZUDRzaFMzVlhjTkdGYjkvbzkvTjkzbitKZUFpSGd4YU5pQjN6YVV0a05XWWs0Vgozang2d0psTythOUNxdGJJeXg2ZzdyTHhOanVqRFpRZTZGcUdsMzVkVDR5MHA2UmVuUWQ4b1p5aWw3dlpqSkJaCjluTWRJblMyU05wWUZFclBsb25rdXNZKzZsam9TbFNLMXVSRmd2S3dzeGE3RmROMXZWSnRJQk9qdVJzSk9DaHYKOTB2K0ZEQWwxSnNZVUNPUnByUmtMWXB2TWI4Q1BZaUlzb3JmTUdKNnI3NktYUEIzRS9xejRmaWJ1UmZVeWJxMgp5eGxRTVJKb216d1BPemUrbWRQUU5Hd3VTTjU0VnByYXhoNGFpcWtaUVBsSWpRb1dFaFVKRWxMb0NtQXZ4TmtxCmRBcVZJMXZ3cS9FRXFBTEh3amJKRXIwQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=
|
85
|
+
X-Smaak-Identifier: service-provider-public
|
86
|
+
X-Smaak-Psk: 917e5f9bcf6d7c20a338d8a39bbf79ef
|
87
|
+
X-Smaak-Expires: 1435225695
|
88
|
+
X-Smaak-Nonce: 7211840395
|
89
|
+
X-Smaak-Encrypt: false
|
90
|
+
Content-Type: text/plain
|
91
|
+
Content-Length: 20
|
92
|
+
... (redacted)
|
93
|
+
{"index1":"data1"}
|
94
|
+
|
95
|
+
### Encrypted
|
96
|
+
POST http://service-provider-internal:9393/secure-service HTTP/1.1
|
97
|
+
Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
98
|
+
Accept: */*
|
99
|
+
User-Agent: Ruby
|
100
|
+
Authorization: Signature keyId="rsa-key-1",algorithm="rsa-sha256", headers="host date digest x-smaak-recipient x-smaak-identifier x-smaak-psk x-smaak-expires x-smaak-nonce x-smaak-encrypt content-length", signature="Y9v95p/rUAp3mmrZHKKvc4FVcDkCQuBVqArvhu70REQrIuHjJ2HDdS2xcQc1t41Ff+lRhO1aWf60cd+Be+1Q/8qsm5G35S66R9sQVr79h/zXovsimWw+GWmHj/d2RecgvGC9SXLRLchPSibYWiV1H5UlkCSqZEYevwFf17LlAk6mRVvFzcB50F+mYglcAFMQhFQI68JMN6CJWUrp09q5DH44WlsaCwUdmbn7pVAnbO6z45OtHPBjVoHtSFkGeFqhndkSRiXWrd9joXPqCb4VRUNG+NZk/9gU17yxkg63cheurf29EmRjAMDkP3nd/VGseOjNzm6MHjdgF9qrQxoQLleNb/lZcZB/ldCimR3AV0thu21NcSpOr1dIlKZX0oyiUOijPXCjXiOEtfO2wqsRk42c8b8nLJJUnFoDvWLQrY7ZFWnzeuu6OPVZQELcSsXokssz8Wsa+RjWo4HoQzfRi12/P1fDZVgj5EkNfUK/R/3ROR11XqdRaIiSXU8SIkof7iCe/2nGOLQNDmhQB6DWKRbN6Sl6bKB00Wto0t1yeeDyLcTrCDmJpKGS3L8hC671cT2f8nv4zHeDZUCqEVdvbcpbOILh9BxoxtLkhOhoAamdebOeOESDQEHwPvXHg9e46cQjGkdMxNgO/CyhSzVM6I5P60Sbn6ppHgsZ5w8ymPA="
|
101
|
+
Host: service-provider-internal
|
102
|
+
Date: 2015-06-25 09:45:34 GMT
|
103
|
+
Digest: SHA-256=3f4502e658dd304d4cd1004a83935ede11692751011a410134ba861a1b55df92
|
104
|
+
X-Smaak-Recipient: LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUFxL2tiYjdBNWllQWV1WlBBVnI3MAo5cjl1TkFzc2dmYkdjeGMzZTc3RDNndkY4U2tzbURNQmQyTUt5TUh0ZjBrM1pqSVdZemJJVG5jQXM1Nnd4cmRSClhiVHpIZnhjMll1dDMwd0ljR2YvUVk4ZTJXNmdMWko4aVM3MXlYb0JQNFpEc2lLSXd4ajFsenYyVFlXWnNSL3EKd28xSzBxZ1NzOXJJVEVkWDVqampycHBYWTdobHNPMGVKQ2JBRG0weEtnU1hMcFQycnJzUnJ2OFllRXFvZTRMaQpDOFd6RjZZRlh1U3RHR1E4SXlxbjdPaTN5aVU2WFc3OTl2cFpIeHJlaERYaytDalZuU0ZXWkVPUHg3cENpam9SCnlXb0gyUmR6QVpQczdVdVJWOUdGWWFQeHRudmttNVdVZDVTdWVCNlMxT2E4dVZ3UnpyeXl6WkRjdG0xdWs1VjIKUE0zLzFqbFJMbFJzTWxSeHdZUDRzaFMzVlhjTkdGYjkvbzkvTjkzbitKZUFpSGd4YU5pQjN6YVV0a05XWWs0Vgozang2d0psTythOUNxdGJJeXg2ZzdyTHhOanVqRFpRZTZGcUdsMzVkVDR5MHA2UmVuUWQ4b1p5aWw3dlpqSkJaCjluTWRJblMyU05wWUZFclBsb25rdXNZKzZsam9TbFNLMXVSRmd2S3dzeGE3RmROMXZWSnRJQk9qdVJzSk9DaHYKOTB2K0ZEQWwxSnNZVUNPUnByUmtMWXB2TWI4Q1BZaUlzb3JmTUdKNnI3NktYUEIzRS9xejRmaWJ1UmZVeWJxMgp5eGxRTVJKb216d1BPemUrbWRQUU5Hd3VTTjU0VnByYXhoNGFpcWtaUVBsSWpRb1dFaFVKRWxMb0NtQXZ4TmtxCmRBcVZJMXZ3cS9FRXFBTEh3amJKRXIwQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=
|
105
|
+
X-Smaak-Identifier: service-provider-public
|
106
|
+
X-Smaak-Psk: 917e5f9bcf6d7c20a338d8a39bbf79ef
|
107
|
+
X-Smaak-Expires: 1435225536
|
108
|
+
X-Smaak-Nonce: 1443964335
|
109
|
+
X-Smaak-Encrypt: true
|
110
|
+
Content-Type: text/plain
|
111
|
+
Content-Length: 684
|
112
|
+
... (redacted)
|
113
|
+
fBkLECsy+UXmfx08eoFf45dtRgzkOCsoH2yh3CjsnSpiPKuXz+O5KVYvM/VpWrtYs11h5rwl563wwDoLuSQLgWzeiNoyJ4jttqcawVLG+
|
4
114
|
|
5
|
-
This gem is sponsored by Hetzner (Pty) Ltd - http://hetzner.co.za
|
6
115
|
|
7
116
|
## Installation
|
8
117
|
|
@@ -18,52 +127,6 @@ Or install it yourself as:
|
|
18
127
|
|
19
128
|
$ gem install smaak
|
20
129
|
|
21
|
-
## Use cases
|
22
|
-
|
23
|
-
This gem and mechanism attempts to alleviate the following attacks and concerns for inter-service communication. This is not a public client/server mechanism.
|
24
|
-
|
25
|
-
Man-in-the-middle attack:
|
26
|
-
|
27
|
-
Use this gem to communicate inside an HTTPS tunnel that you trust. For internal private networks, place a secure CA on your network.
|
28
|
-
|
29
|
-
Masquerading:
|
30
|
-
|
31
|
-
Each association made requires messages to be signed using that association's keypair.
|
32
|
-
|
33
|
-
Forgery:
|
34
|
-
|
35
|
-
The indentity of the requestor is signed. The HTTP headers, URL and body / querystring is not. Authorization is left to a service that understands the identity and its permissions.
|
36
|
-
|
37
|
-
Replay:
|
38
|
-
|
39
|
-
The request is signed with an expiry and a nonce.
|
40
|
-
|
41
|
-
## Extending
|
42
|
-
|
43
|
-
Designing for future protocol security requirements. The nonce and expiry becomes an optional feature (timestamp header signed could replace these). Passing in a RequestData object for signing, and a RequestSigningValidator object that validates the data content includes all that is required for security, allows us to, in future, cater for man-in-the-middle attacks as well if, for example, the validator requires timestamp, url, form data/querystring, method, etc. to be in the data. If this is all signed, a device in the middle cannot modify the request without the source's private key, and man-in-the-middle is defeated.
|
44
|
-
|
45
|
-
Though arbitrary data can be added for signature and an arbitrary validator provided to ensure the data and headers required are included for signing on request, on the server side the payload is not automatically checked by the Smaak::Server class. Ensure after verification of signature that you look in message_data['data'] and verify your payload has what you require to prove no man-in-the middle modification. This could be automated in a future release.
|
46
|
-
|
47
|
-
## Usage
|
48
|
-
|
49
|
-
Inject your certs and pre-shared keys from the environment, or configuration files or configuration service, etc. User your HTTP library of choice.
|
50
|
-
|
51
|
-
### Client
|
52
|
-
|
53
|
-
client = Smaak::Client.new
|
54
|
-
client.set_identity('service-provider-public')
|
55
|
-
client.set_private_key(File.read '/home/ubuntu/secure/service-provider-public.pem')
|
56
|
-
client.add_association('service-provider-internal', File.read('/home/ubuntu/secure/service-provider-internal-pub.pem'), 'pre-shared-key')
|
57
|
-
|
58
|
-
response = Unirest.get "http://service-provider-internal:9393/secure-service", headers:{ "Authorization" => client.build_auth_header('service-provider-internal') }, parameters:{ :someparam => 'somevalue'] }
|
59
|
-
|
60
|
-
### Server
|
61
|
-
|
62
|
-
server = Smaak::Server.new
|
63
|
-
server.set_public_key(File.read('/home/ubuntu/secure/service-provider-internal-pub.pem'))
|
64
|
-
server.add_association('service-provider-public', File.read('/home/ubuntu/secure/service-provider-public-pub.pem'), 'pre-shared-key')
|
65
|
-
requestor = server.verify_signed_request(request)
|
66
|
-
|
67
130
|
## Contributing
|
68
131
|
|
69
132
|
Please send feedback and comments to the author at:
|
@@ -72,8 +135,4 @@ Inject your certs and pre-shared keys from the environment, or configuration fil
|
|
72
135
|
|
73
136
|
Thanks to Sheldon Hearn for review and great ideas that unblocked complex challenges (https://rubygems.org/profiles/sheldonh).
|
74
137
|
|
75
|
-
|
76
|
-
2. Create your feature branch (`git checkout -b my-new-feature`)
|
77
|
-
3. Commit your changes (`git commit -am 'Add some feature'`)
|
78
|
-
4. Push to the branch (`git push origin my-new-feature`)
|
79
|
-
5. Create new Pull Request
|
138
|
+
This gem is sponsored by Hetzner (Pty) Ltd - http://hetzner.co.za
|
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'smaak'
|
2
|
+
require 'smaak/utils'
|
3
|
+
require 'uri'
|
4
|
+
|
5
|
+
module Smaak
|
6
|
+
class NetHttpAdaptor
|
7
|
+
attr_reader :request
|
8
|
+
|
9
|
+
def initialize(request)
|
10
|
+
raise ArgumentError.new("Must provide a Net::HTTPRequest") if not request.is_a? Net::HTTPRequest
|
11
|
+
@request = request
|
12
|
+
end
|
13
|
+
|
14
|
+
def set_header(header, value)
|
15
|
+
raise ArgumentError.new("Header must be a non-blank string") if not Smaak::Utils::non_blank_string?(header)
|
16
|
+
@request[header] = value
|
17
|
+
end
|
18
|
+
|
19
|
+
def each_header(&block)
|
20
|
+
@request.each_header(&block)
|
21
|
+
end
|
22
|
+
|
23
|
+
def host
|
24
|
+
URI.parse(@request.path).host
|
25
|
+
end
|
26
|
+
|
27
|
+
def path
|
28
|
+
URI.parse(@request.path).path
|
29
|
+
end
|
30
|
+
|
31
|
+
def method
|
32
|
+
@request.method
|
33
|
+
end
|
34
|
+
|
35
|
+
def body
|
36
|
+
@request.body
|
37
|
+
end
|
38
|
+
|
39
|
+
def body=(body)
|
40
|
+
@request.body = body
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'smaak'
|
2
|
+
require 'uri'
|
3
|
+
|
4
|
+
module Smaak
|
5
|
+
class RackAdaptor
|
6
|
+
attr_reader :request
|
7
|
+
|
8
|
+
def initialize(request)
|
9
|
+
raise ArgumentError.new("Must provide a Net::HTTPRequest") if not request.is_a? Rack::Request
|
10
|
+
@request = request
|
11
|
+
end
|
12
|
+
|
13
|
+
def header(header)
|
14
|
+
raise ArgumentError.new("Header must be a non-blank string") if not Smaak::Utils::non_blank_string?(header)
|
15
|
+
return value = @request.env["CONTENT_LENGTH"] if header == "content-length"
|
16
|
+
return value = @request.env["REQUEST_METHOD"] if header == "request-method"
|
17
|
+
return @request.env["HTTP_#{header.upcase.gsub("-", "_")}"]
|
18
|
+
end
|
19
|
+
|
20
|
+
def method
|
21
|
+
@request.env["REQUEST_METHOD"]
|
22
|
+
end
|
23
|
+
|
24
|
+
def path
|
25
|
+
@request.env["PATH_INFO"]
|
26
|
+
end
|
27
|
+
|
28
|
+
def body
|
29
|
+
@request.body
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
data/lib/smaak/associate.rb
CHANGED
@@ -11,10 +11,8 @@ module Smaak
|
|
11
11
|
@token_life = Smaak::DEFAULT_TOKEN_LIFE
|
12
12
|
end
|
13
13
|
|
14
|
-
def
|
15
|
-
|
16
|
-
raise ArgumentError.new("Key needs to be valid") if not validate_key(the_key)
|
17
|
-
@association_store[identity] = { 'public_key' => the_key, 'psk' => psk }
|
14
|
+
def set_key(key)
|
15
|
+
@key = adapt_rsa_key(key)
|
18
16
|
|
19
17
|
rescue OpenSSL::PKey::RSAError
|
20
18
|
raise ArgumentError.new("Key needs to be valid")
|
@@ -25,15 +23,23 @@ module Smaak
|
|
25
23
|
@token_life = token_life
|
26
24
|
end
|
27
25
|
|
28
|
-
def
|
26
|
+
def add_association(identifier, key, psk, encrypt = false)
|
29
27
|
the_key = key.is_a?(String) ? OpenSSL::PKey::RSA.new(key) : key
|
30
28
|
raise ArgumentError.new("Key needs to be valid") if not validate_key(the_key)
|
31
|
-
@
|
29
|
+
@association_store[identifier] = { 'public_key' => the_key, 'psk' => psk, 'encrypt' => encrypt }
|
32
30
|
|
33
31
|
rescue OpenSSL::PKey::RSAError
|
34
32
|
raise ArgumentError.new("Key needs to be valid")
|
35
33
|
end
|
36
34
|
|
35
|
+
protected
|
36
|
+
|
37
|
+
def adapt_rsa_key(key)
|
38
|
+
the_key = key.is_a?(String) ? OpenSSL::PKey::RSA.new(key) : key
|
39
|
+
raise ArgumentError.new("Key needs to be valid") if not validate_key(the_key)
|
40
|
+
the_key
|
41
|
+
end
|
42
|
+
|
37
43
|
private
|
38
44
|
|
39
45
|
def validate_key(key)
|
data/lib/smaak/auth_message.rb
CHANGED
@@ -1,57 +1,69 @@
|
|
1
|
+
require 'smaak/crypto'
|
2
|
+
|
1
3
|
module Smaak
|
2
4
|
class AuthMessage
|
3
|
-
attr_reader :
|
4
|
-
attr_reader :message_data
|
5
|
-
attr_reader :identity
|
5
|
+
attr_reader :identifier
|
6
6
|
attr_reader :nonce
|
7
|
+
attr_reader :recipient
|
8
|
+
attr_reader :psk
|
9
|
+
attr_reader :expires
|
10
|
+
attr_reader :encrypt
|
11
|
+
|
12
|
+
def self.create(recipient_public_key, psk, token_life, identifier, encrypt = false)
|
13
|
+
nonce = Smaak::Crypto::generate_nonce
|
14
|
+
expires = Time.now.to_i + token_life
|
15
|
+
#Must obfuscate PSK. AuthMessage must always have an obfuscated PSK
|
16
|
+
psk = Smaak::Crypto::obfuscate_psk(psk)
|
17
|
+
AuthMessage::build(recipient_public_key, psk, expires, identifier, nonce, encrypt)
|
18
|
+
end
|
7
19
|
|
8
|
-
def
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
raise ArgumentError.new("Message must have a valid
|
19
|
-
@
|
20
|
-
@identity.freeze
|
21
|
-
raise ArgumentError.new("Message must have a valid identity set") if @identity.nil? or @identity.empty?
|
22
|
-
@nonce = @message_data['nonce']
|
20
|
+
def self.build(recipient_public_key, psk, expires, identifier, nonce, encrypt = false)
|
21
|
+
#No need to obfuscate PSK. Off the wire we should always expect an obfuscated PSK
|
22
|
+
AuthMessage.new(identifier, nonce, expires, psk, recipient_public_key, encrypt)
|
23
|
+
end
|
24
|
+
|
25
|
+
def initialize(identifier, nonce, expires, psk, recipient_public_key, encrypt)
|
26
|
+
raise ArgumentError.new("Message must have a valid identifier set") if identifier.nil? or identifier.empty?
|
27
|
+
@identifier = identifier
|
28
|
+
@identifier.freeze
|
29
|
+
|
30
|
+
raise ArgumentError.new("Message must have a valid nonce set") if not validate_nonce(nonce)
|
31
|
+
@nonce = nonce
|
23
32
|
@nonce.freeze
|
24
|
-
|
33
|
+
|
34
|
+
@recipient = recipient_public_key
|
35
|
+
@psk = psk
|
36
|
+
|
37
|
+
raise ArgumentError.new("Message must have a valid expiry set") if not validate_expiry(expires)
|
38
|
+
@expires = expires
|
39
|
+
set_encrypt(encrypt)
|
25
40
|
end
|
26
41
|
|
27
|
-
def
|
28
|
-
@
|
42
|
+
def set_encrypt(encrypt)
|
43
|
+
@encrypt = false
|
44
|
+
@encrypt = true if encrypt == "true" or encrypt == true
|
29
45
|
end
|
30
46
|
|
31
|
-
def
|
32
|
-
|
33
|
-
return false if pubkey.nil?
|
34
|
-
digest = OpenSSL::Digest::SHA256.new
|
35
|
-
pubkey.verify(digest, signature, @message)
|
47
|
+
def expired?
|
48
|
+
@expires.to_i < Time.now.to_i
|
36
49
|
end
|
37
50
|
|
38
51
|
def psk_match?(psk)
|
39
52
|
return false if psk.nil?
|
40
|
-
return false if @
|
41
|
-
@
|
53
|
+
return false if @psk.nil?
|
54
|
+
@psk == Smaak::Crypto::obfuscate_psk(psk)
|
42
55
|
end
|
43
56
|
|
44
57
|
def intended_for_recipient?(pubkey)
|
45
58
|
return false if pubkey.nil?
|
46
|
-
return false if @
|
47
|
-
@
|
59
|
+
return false if @recipient.nil?
|
60
|
+
@recipient == pubkey
|
48
61
|
end
|
49
62
|
|
50
|
-
def verify(
|
63
|
+
def verify(psk)
|
51
64
|
return false if expired?
|
52
|
-
return false if not
|
53
|
-
|
54
|
-
identity
|
65
|
+
return false if not psk_match?(psk)
|
66
|
+
true
|
55
67
|
end
|
56
68
|
|
57
69
|
private
|
@@ -60,14 +72,18 @@ module Smaak
|
|
60
72
|
return false if nonce.nil?
|
61
73
|
return false if nonce.to_i == 0
|
62
74
|
true
|
75
|
+
|
76
|
+
rescue
|
77
|
+
false
|
63
78
|
end
|
64
79
|
|
65
|
-
def validate_expiry
|
66
|
-
return false if
|
67
|
-
return false if not
|
68
|
-
return false if @message_data['expires'].nil?
|
69
|
-
return false if not (@message_data['expires'].to_i > 0)
|
80
|
+
def validate_expiry(expires)
|
81
|
+
return false if expires.nil?
|
82
|
+
return false if not (expires.to_i > 0)
|
70
83
|
true
|
84
|
+
|
85
|
+
rescue
|
86
|
+
false
|
71
87
|
end
|
72
88
|
end
|
73
89
|
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
require 'smaak'
|
2
|
+
|
3
|
+
module Smaak
|
4
|
+
class Cavage04
|
5
|
+
SPECIFICATION = "https://datatracker.ietf.org/doc/draft-cavage-http-signatures/04/" unless defined? SPECIFICATION; SPECIFICATION.freeze
|
6
|
+
attr_reader :adaptor
|
7
|
+
attr_reader :headers_to_be_signed
|
8
|
+
|
9
|
+
def initialize(adaptor)
|
10
|
+
raise ArgumentError.new("Must provide a valid request adaptor") if adaptor.nil?
|
11
|
+
@adaptor = adaptor
|
12
|
+
@headers_to_be_signed = Smaak::Cavage04::headers_to_be_signed + Smaak::headers_to_be_signed
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.headers_to_be_signed
|
16
|
+
[ "(request-target)",
|
17
|
+
"host",
|
18
|
+
"date",
|
19
|
+
"digest",
|
20
|
+
"content-length" ]
|
21
|
+
end
|
22
|
+
|
23
|
+
def compile_auth_header(signature)
|
24
|
+
raise ArgumentError.new("invalid signature") if not Smaak::Utils::non_blank_string?(signature)
|
25
|
+
ordered_headers = ""
|
26
|
+
@adaptor.each_header do |header, value|
|
27
|
+
ordered_headers = "#{ordered_headers} #{header}" if @headers_to_be_signed.include?(header)
|
28
|
+
end
|
29
|
+
ordered_headers = ordered_headers[1..ordered_headers.size]
|
30
|
+
@adaptor.set_header("authorization", "Signature keyId=\"rsa-key-1\",algorithm=\"rsa-sha256\", headers=\"#{ordered_headers}\", signature=\"#{signature}\"")
|
31
|
+
end
|
32
|
+
|
33
|
+
def compile_signature_headers(auth_message)
|
34
|
+
body = @adaptor.body.nil? ? "" : @adaptor.body
|
35
|
+
@adaptor.set_header("authorization", "")
|
36
|
+
@adaptor.set_header("host", "#{@adaptor.host}")
|
37
|
+
@adaptor.set_header("date", "#{gmt_now}")
|
38
|
+
@adaptor.set_header("digest", "SHA-256=#{Digest::SHA256.hexdigest(body)}")
|
39
|
+
@adaptor.set_header("x-smaak-recipient", "#{Smaak::Crypto::encode64(auth_message.recipient)}")
|
40
|
+
@adaptor.set_header("x-smaak-identifier", "#{auth_message.identifier}")
|
41
|
+
@adaptor.set_header("x-smaak-psk", "#{auth_message.psk}")
|
42
|
+
@adaptor.set_header("x-smaak-expires", "#{auth_message.expires}")
|
43
|
+
@adaptor.set_header("x-smaak-nonce", "#{auth_message.nonce}")
|
44
|
+
@adaptor.set_header("x-smaak-encrypt", "#{auth_message.encrypt}")
|
45
|
+
@adaptor.set_header("content-type", "text/plain")
|
46
|
+
@adaptor.set_header("content-length", "#{body.size}")
|
47
|
+
|
48
|
+
signature_headers = ""
|
49
|
+
@adaptor.each_header do |header, value|
|
50
|
+
signature_headers = append_header(signature_headers, "#{header}: #{value}") if @headers_to_be_signed.include? header
|
51
|
+
end
|
52
|
+
signature_headers = prepend_header("(request-target)", "#{@adaptor.method.downcase} #{@adaptor.path}", signature_headers)
|
53
|
+
end
|
54
|
+
|
55
|
+
def extract_signature_headers
|
56
|
+
@adaptor.header("authorization") =~ /headers=\"([^"]*)\",/
|
57
|
+
headers_order = $1.split(' ')
|
58
|
+
|
59
|
+
signature_headers = ""
|
60
|
+
headers_order.each do |header|
|
61
|
+
signature_headers = append_header(signature_headers, "#{header}: #{@adaptor.header(header)}")
|
62
|
+
end
|
63
|
+
signature_headers = prepend_header("(request-target)", "#{@adaptor.method.downcase} #{@adaptor.path}", signature_headers)
|
64
|
+
end
|
65
|
+
|
66
|
+
def extract_signature
|
67
|
+
@adaptor.header("authorization") =~ /signature=\"([^"]*)\"/
|
68
|
+
$1
|
69
|
+
end
|
70
|
+
private
|
71
|
+
|
72
|
+
def gmt_now
|
73
|
+
Time.now.gmtime.to_s.gsub("UTC", "GMT")
|
74
|
+
end
|
75
|
+
|
76
|
+
def append_header(header_list, header)
|
77
|
+
header_list = "#{header_list}\n#{header}"
|
78
|
+
end
|
79
|
+
|
80
|
+
def prepend_header(header, value, signature_headers)
|
81
|
+
"#{header}: #{value}#{signature_headers}"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
data/lib/smaak/client.rb
CHANGED
@@ -1,41 +1,58 @@
|
|
1
|
+
require 'uri'
|
1
2
|
require 'smaak.rb'
|
2
3
|
require 'smaak/associate.rb'
|
3
|
-
require 'smaak/
|
4
|
+
require 'smaak/auth_message.rb'
|
4
5
|
|
5
6
|
module Smaak
|
6
7
|
class Client < Associate
|
7
|
-
|
8
|
-
attr_accessor :private_key
|
8
|
+
attr_reader :identifier
|
9
9
|
|
10
10
|
def set_private_key(key)
|
11
11
|
set_key(key)
|
12
12
|
end
|
13
13
|
|
14
|
-
def
|
15
|
-
|
14
|
+
def set_identifier(identifier)
|
15
|
+
raise ArgumentError.new("Invalid identifier") if not Smaak::Utils::non_blank_string?(identifier)
|
16
|
+
@identifier = identifier
|
16
17
|
end
|
17
18
|
|
18
|
-
def
|
19
|
-
|
20
|
-
|
21
|
-
raise ArgumentError.new("
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
19
|
+
def sign_request(associate_identifier, adaptor)
|
20
|
+
raise ArgumentError.new("Associate invalid") if not validate_associate(associate_identifier)
|
21
|
+
associate = @association_store[associate_identifier]
|
22
|
+
raise ArgumentError.new("Invalid adaptor") if adaptor.nil?
|
23
|
+
auth_message = Smaak::AuthMessage.create(associate['public_key'].export, associate['psk'], @token_life, @identifier, associate['encrypt'])
|
24
|
+
adaptor.body = Smaak::Crypto::encrypt(adaptor.body, associate['public_key']) if auth_message.encrypt
|
25
|
+
adaptor = Smaak::sign_authorization_headers(@key, auth_message, adaptor, Smaak::Cavage04::SPECIFICATION)
|
26
|
+
end
|
27
|
+
|
28
|
+
def get(identifier, uri, body, ssl = false, ssl_verify = OpenSSL::SSL::VERIFY_PEER)
|
29
|
+
connect(Net::HTTP::Get, identifier, uri, body, ssl, ssl_verify)
|
30
|
+
end
|
31
|
+
|
32
|
+
def post(identifier, uri, body, ssl = false, ssl_verify = OpenSSL::SSL::VERIFY_PEER)
|
33
|
+
connect(Net::HTTP::Post, identifier, uri, body, ssl, ssl_verify)
|
31
34
|
end
|
32
35
|
|
33
36
|
private
|
34
37
|
|
35
|
-
def validate_associate(
|
36
|
-
return false if
|
37
|
-
return false if @association_store[
|
38
|
+
def validate_associate(associate_identifier)
|
39
|
+
return false if associate_identifier.nil?
|
40
|
+
return false if @association_store[associate_identifier].nil?
|
38
41
|
true
|
39
42
|
end
|
43
|
+
|
44
|
+
def connect(connector, identifier, uri, body, ssl, ssl_verify)
|
45
|
+
url = URI.parse(uri)
|
46
|
+
http = Net::HTTP.new(url.host, url.port)
|
47
|
+
http.use_ssl = ssl
|
48
|
+
http.verify_mode = ssl_verify
|
49
|
+
req = connector.new(url.to_s)
|
50
|
+
req.body = body
|
51
|
+
adaptor = Smaak::create_adaptor(req)
|
52
|
+
req = (sign_request(identifier, adaptor)).request
|
53
|
+
response = http.request(req)
|
54
|
+
response.body = Smaak::Crypto::decrypt(response.body, @key) if @association_store[identifier]['encrypt']
|
55
|
+
response
|
56
|
+
end
|
40
57
|
end
|
41
58
|
end
|
data/lib/smaak/crypto.rb
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
module Smaak
|
2
|
+
class Crypto
|
3
|
+
def self.obfuscate_psk(psk)
|
4
|
+
Digest::MD5.hexdigest(psk.reverse)
|
5
|
+
end
|
6
|
+
|
7
|
+
def self.generate_nonce
|
8
|
+
SecureRandom::random_number(10000000000)
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.encode64(data)
|
12
|
+
Base64.strict_encode64(data)
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.decode64(data)
|
16
|
+
Base64.strict_decode64(data)
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.sign_data(data, private_key)
|
20
|
+
digest = OpenSSL::Digest::SHA256.new
|
21
|
+
private_key.sign(digest, Smaak::Crypto::encode64(data))
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.verify_signature(signature, data, public_key)
|
25
|
+
digest = OpenSSL::Digest::SHA256.new
|
26
|
+
public_key.verify(digest, signature, data)
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.encrypt(data, public_key)
|
30
|
+
Base64.strict_encode64(public_key.public_encrypt(data))
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.decrypt(data, private_key)
|
34
|
+
private_key.private_decrypt(Base64.strict_decode64(data))
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.sink(stream)
|
38
|
+
data = ""
|
39
|
+
while t = stream.gets do
|
40
|
+
data = data + t
|
41
|
+
end
|
42
|
+
data
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|