linzer 0.7.7 → 0.7.9.beta1

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/LICENSE.txt +1 -1
  4. data/README.md +3 -1
  5. data/flake.lock +109 -0
  6. data/flake.nix +73 -0
  7. data/lib/linzer/common.rb +51 -0
  8. data/lib/linzer/ecdsa.rb +51 -0
  9. data/lib/linzer/ed25519.rb +35 -0
  10. data/lib/linzer/helper.rb +79 -0
  11. data/lib/linzer/hmac.rb +47 -1
  12. data/lib/linzer/http/bootstrap.rb +11 -0
  13. data/lib/linzer/http/signature_feature.rb +53 -1
  14. data/lib/linzer/http.rb +54 -0
  15. data/lib/linzer/jws.rb +74 -0
  16. data/lib/linzer/key/helper.rb +186 -10
  17. data/lib/linzer/key.rb +73 -0
  18. data/lib/linzer/message/adapter/abstract.rb +75 -10
  19. data/lib/linzer/message/adapter/generic/request.rb +27 -0
  20. data/lib/linzer/message/adapter/generic/response.rb +17 -0
  21. data/lib/linzer/message/adapter/http_gem/request.rb +11 -0
  22. data/lib/linzer/message/adapter/http_gem/response.rb +8 -5
  23. data/lib/linzer/message/adapter/net_http/request.rb +7 -0
  24. data/lib/linzer/message/adapter/net_http/response.rb +4 -0
  25. data/lib/linzer/message/adapter/rack/common.rb +14 -6
  26. data/lib/linzer/message/adapter/rack/request.rb +13 -0
  27. data/lib/linzer/message/adapter/rack/response.rb +11 -0
  28. data/lib/linzer/message/adapter.rb +17 -0
  29. data/lib/linzer/message/field/parser.rb +14 -0
  30. data/lib/linzer/message/field.rb +32 -2
  31. data/lib/linzer/message/wrapper.rb +20 -0
  32. data/lib/linzer/message.rb +113 -3
  33. data/lib/linzer/options.rb +13 -0
  34. data/lib/linzer/rsa.rb +34 -0
  35. data/lib/linzer/rsa_pss.rb +44 -0
  36. data/lib/linzer/signature.rb +113 -1
  37. data/lib/linzer/signer.rb +69 -0
  38. data/lib/linzer/verifier.rb +52 -0
  39. data/lib/linzer/version.rb +3 -1
  40. data/lib/linzer.rb +104 -0
  41. data/lib/rack/auth/signature.rb +90 -6
  42. metadata +21 -27
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fcae916cefddaddfc6fd6b58ab110eceda2225e5b525c984fa20eba378d80262
4
- data.tar.gz: 896938aeafbca006f944a950bd0151df60f960ee7541ee98fe6aff2ffee9754f
3
+ metadata.gz: 1e68e3de6c89b69da86e383a47e365ab275597440ac71ffb6ce261cd10ad9f51
4
+ data.tar.gz: d036d8264a8bba7a7d964432e187725da146f228ee81335d3db5c3659d1578ee
5
5
  SHA512:
6
- metadata.gz: 89b1873e0664d7048142c9a16fd5414f3f568eeb01a3396d194398efa59e9e1bd40596096a433be17fa6635be9850cb641cd48b79d8ac99efe21d128c822337f
7
- data.tar.gz: 61a79c4ef12ae7fbf74da3c0d99786caaa010255d099bb7c73319308a86895a4b5d531254f4bd3a7f78fb15aec1cc96f01bb02f83fa45313bae0c8910ca45b1b
6
+ metadata.gz: b635cf2b46a0235a7e4131719b225df2a8c03b689c175baf4d9d234dcb1e1ad9f2b4ad1b52da19296444e2883064dbf11e69509ebce96695e3ee51fe7aa31f45
7
+ data.tar.gz: 729797295ce7a27128046cd6ae9714d0a74e6ba64f3db74e9f4fe48106548fd911c60a2f9442e387a5a04e82c54644294587f5b61e2d3aaa21d6a09aa5e1681e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.7.9.beta1] - 2026-03-03
4
+
5
+ (Beta release to test gem release automation; no functional changes)
6
+
7
+ ## [0.7.8] - 2026-03-03
8
+
9
+ - Security fix: HMAC signature verification now uses constant-time
10
+ comparison to prevent timing attacks.
11
+
3
12
  ## [0.7.7] - 2025-07-08
4
13
 
5
14
  (No changes since the last beta release, this new stable release just
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2024-2025 Miguel Landaeta <miguel@miguel.cc>
3
+ Copyright (c) 2024-2026 Miguel Landaeta <miguel@miguel.cc>
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Linzer [![Latest Version][gem-badge]][gem-link] [![License: MIT][license-image]][license-link] [![CI Status][ci-image]][ci-link]
1
+ # Linzer [![Latest Version][gem-badge]][gem-link] [![License: MIT][license-image]][license-link] [![CI Status][ci-image]][ci-link] [![RubyDoc][rubydoc-badge]][rubydoc-link]
2
2
 
3
3
  [gem-badge]: https://badge.fury.io/rb/linzer.svg
4
4
  [gem-link]: https://rubygems.org/gems/linzer
@@ -6,6 +6,8 @@
6
6
  [license-link]: https://github.com/nomadium/linzer/blob/master/LICENSE.txt
7
7
  [ci-image]: https://github.com/nomadium/linzer/actions/workflows/main.yml/badge.svg?branch=master
8
8
  [ci-link]: https://github.com/nomadium/linzer/actions/workflows/main.yml
9
+ [rubydoc-badge]: https://img.shields.io/badge/docs-RubyDoc.info-blue
10
+ [rubydoc-link]: https://www.rubydoc.info/gems/linzer
9
11
 
10
12
  Linzer is a Ruby library for [HTTP Message Signatures (RFC 9421)](https://www.rfc-editor.org/rfc/rfc9421.html).
11
13
 
data/flake.lock ADDED
@@ -0,0 +1,109 @@
1
+ {
2
+ "nodes": {
3
+ "bundix": {
4
+ "inputs": {
5
+ "nixpkgs": "nixpkgs"
6
+ },
7
+ "locked": {
8
+ "lastModified": 1762235257,
9
+ "narHash": "sha256-QyXFgaXQETr9BmwUbWM/2EtF+my6Arpt71xItZy3NL8=",
10
+ "owner": "inscapist",
11
+ "repo": "bundix",
12
+ "rev": "2233b24084316b4de1d55321483554f457a09cfc",
13
+ "type": "github"
14
+ },
15
+ "original": {
16
+ "owner": "inscapist",
17
+ "repo": "bundix",
18
+ "type": "github"
19
+ }
20
+ },
21
+ "nixpkgs": {
22
+ "locked": {
23
+ "lastModified": 1761880412,
24
+ "narHash": "sha256-QoJjGd4NstnyOG4mm4KXF+weBzA2AH/7gn1Pmpfcb0A=",
25
+ "owner": "NixOS",
26
+ "repo": "nixpkgs",
27
+ "rev": "a7fc11be66bdfb5cdde611ee5ce381c183da8386",
28
+ "type": "github"
29
+ },
30
+ "original": {
31
+ "id": "nixpkgs",
32
+ "type": "indirect"
33
+ }
34
+ },
35
+ "nixpkgs_2": {
36
+ "locked": {
37
+ "lastModified": 1770115704,
38
+ "narHash": "sha256-KHFT9UWOF2yRPlAnSXQJh6uVcgNcWlFqqiAZ7OVlHNc=",
39
+ "owner": "NixOS",
40
+ "repo": "nixpkgs",
41
+ "rev": "e6eae2ee2110f3d31110d5c222cd395303343b08",
42
+ "type": "github"
43
+ },
44
+ "original": {
45
+ "owner": "NixOS",
46
+ "ref": "nixos-unstable",
47
+ "repo": "nixpkgs",
48
+ "type": "github"
49
+ }
50
+ },
51
+ "nixpkgs_3": {
52
+ "locked": {
53
+ "lastModified": 1678875422,
54
+ "narHash": "sha256-T3o6NcQPwXjxJMn2shz86Chch4ljXgZn746c2caGxd8=",
55
+ "owner": "NixOS",
56
+ "repo": "nixpkgs",
57
+ "rev": "126f49a01de5b7e35a43fd43f891ecf6d3a51459",
58
+ "type": "github"
59
+ },
60
+ "original": {
61
+ "id": "nixpkgs",
62
+ "type": "indirect"
63
+ }
64
+ },
65
+ "root": {
66
+ "inputs": {
67
+ "bundix": "bundix",
68
+ "nixpkgs": "nixpkgs_2",
69
+ "ruby-nix": "ruby-nix",
70
+ "systems": "systems"
71
+ }
72
+ },
73
+ "ruby-nix": {
74
+ "inputs": {
75
+ "nixpkgs": "nixpkgs_3"
76
+ },
77
+ "locked": {
78
+ "lastModified": 1755059052,
79
+ "narHash": "sha256-yUJmmNIw11ZEIAFogqcqNomk4YV3F/zjwI1f7bYzIyY=",
80
+ "owner": "inscapist",
81
+ "repo": "ruby-nix",
82
+ "rev": "86b498e80058a84461d1f533e121574d85d272d7",
83
+ "type": "github"
84
+ },
85
+ "original": {
86
+ "owner": "inscapist",
87
+ "repo": "ruby-nix",
88
+ "type": "github"
89
+ }
90
+ },
91
+ "systems": {
92
+ "locked": {
93
+ "lastModified": 1689347949,
94
+ "narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=",
95
+ "owner": "nix-systems",
96
+ "repo": "default-linux",
97
+ "rev": "31732fcf5e8fea42e59c2488ad31a0e651500f68",
98
+ "type": "github"
99
+ },
100
+ "original": {
101
+ "owner": "nix-systems",
102
+ "repo": "default-linux",
103
+ "type": "github"
104
+ }
105
+ }
106
+ },
107
+ "root": "root",
108
+ "version": 7
109
+ }
data/flake.nix ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ inputs = {
3
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
4
+ # XXX: should work, not tested yet on MacOS
5
+ # systems.url = "github:nix-systems/default";
6
+ systems.url = "github:nix-systems/default-linux";
7
+ ruby-nix.url = "github:inscapist/ruby-nix";
8
+ bundix.url = "github:inscapist/bundix";
9
+ };
10
+
11
+ outputs = {
12
+ self,
13
+ nixpkgs,
14
+ systems,
15
+ ruby-nix,
16
+ bundix,
17
+ }: let
18
+ eachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f system);
19
+
20
+ makeRubyEnv = system: let
21
+ pkgs = import nixpkgs {inherit system;};
22
+ rubyNix = ruby-nix.lib pkgs;
23
+ gemset = import ./gemset.nix;
24
+ rubyEnv = rubyNix {
25
+ name = "linzer-gems";
26
+ inherit gemset;
27
+ ruby = pkgs.ruby;
28
+ gemConfig = pkgs.defaultGemConfig;
29
+ };
30
+ in {
31
+ inherit pkgs rubyEnv;
32
+ };
33
+ in {
34
+ devShells = eachSystem (system: let
35
+ env = makeRubyEnv system;
36
+ in {
37
+ default = env.pkgs.mkShell {
38
+ packages = [
39
+ env.rubyEnv.ruby
40
+ env.rubyEnv.env
41
+ ];
42
+ };
43
+ });
44
+
45
+ checks = eachSystem (system: let
46
+ env = makeRubyEnv system;
47
+ in {
48
+ default = env.pkgs.stdenv.mkDerivation {
49
+ name = "linzer-unit-tests";
50
+ src = ./.;
51
+
52
+ nativeBuildInputs = [
53
+ env.rubyEnv.ruby
54
+ env.rubyEnv.env
55
+ env.pkgs.git
56
+ ];
57
+
58
+ doCheck = true;
59
+
60
+ checkPhase = ''
61
+ export HOME=$PWD/.nix-home
62
+ mkdir -p $HOME
63
+ rake
64
+ '';
65
+
66
+ installPhase = ''
67
+ mkdir -p $out
68
+ touch $out/done
69
+ '';
70
+ };
71
+ });
72
+ };
73
+ }
data/lib/linzer/common.rb CHANGED
@@ -1,7 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Linzer
4
+ # Shared functionality for signature base computation and validation.
5
+ #
6
+ # This module contains the core logic for building the canonical signature
7
+ # base string that gets signed/verified, as defined in RFC 9421 Section 2.5.
8
+ #
9
+ # @api private
10
+ # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-2.5 RFC 9421 Section 2.5
4
11
  module Common
12
+ # Computes the signature base string for an HTTP message.
13
+ #
14
+ # The signature base is a canonical string representation of the covered
15
+ # components, formatted according to RFC 9421. This is the string that
16
+ # gets cryptographically signed.
17
+ #
18
+ # @param message [Message] The HTTP message
19
+ # @param serialized_components [Array<String>] Serialized component identifiers
20
+ # @param parameters [Hash] Signature parameters (created, keyid, etc.)
21
+ # @return [String] The signature base string
22
+ #
23
+ # @example Signature base format
24
+ # # Each covered component on its own line:
25
+ # # "@method": POST
26
+ # # "@path": /foo
27
+ # # "content-type": application/json
28
+ # # "@signature-params": ("@method" "@path" "content-type");created=1618884473
5
29
  def signature_base(message, serialized_components, parameters)
6
30
  signature_base =
7
31
  serialized_components.each_with_object(+"") do |component, base|
@@ -14,12 +38,25 @@ module Linzer
14
38
  end
15
39
  module_function :signature_base
16
40
 
41
+ # Builds a single line of the signature base for a component.
42
+ #
43
+ # @param component [String] The serialized component identifier
44
+ # @param message [Message] The HTTP message
45
+ # @return [String] The formatted line (e.g., '"@method": POST')
17
46
  def signature_base_line(component, message)
18
47
  field_id = FieldId.new(field_name: component)
19
48
  "%s: %s" % [field_id.serialize, message[field_id]]
20
49
  end
21
50
  module_function :signature_base_line
22
51
 
52
+ # Builds the @signature-params line for the signature base.
53
+ #
54
+ # This is always the last line of the signature base and contains
55
+ # the covered components list and signature parameters.
56
+ #
57
+ # @param serialized_components [Array<String>] The covered components
58
+ # @param parameters [Hash] Signature parameters
59
+ # @return [String] The formatted @signature-params line
23
60
  def signature_params_line(serialized_components, parameters)
24
61
  identifiers = serialized_components.map { |c| Starry.parse_item(c) }
25
62
 
@@ -32,6 +69,13 @@ module Linzer
32
69
 
33
70
  private
34
71
 
72
+ # Validates that all specified components are valid and present.
73
+ #
74
+ # @param message [Message] The HTTP message
75
+ # @param components [Array<String>] Component identifiers to validate
76
+ # @raise [Error] If @signature-params is in the components
77
+ # @raise [Error] If any component is missing from the message
78
+ # @raise [Error] If any component is duplicated
35
79
  def validate_components(message, components)
36
80
  if components.include?('"@signature-params"') ||
37
81
  components.any? { |c| c.start_with?('"@signature-params"') }
@@ -46,6 +90,13 @@ module Linzer
46
90
  validate_uniqueness components
47
91
  end
48
92
 
93
+ # Validates that there are no duplicate components.
94
+ #
95
+ # Components are considered duplicates if they have the same value
96
+ # and parameters, even if serialized differently.
97
+ #
98
+ # @param components [Array<String>] Component identifiers to check
99
+ # @raise [Error] If any component appears more than once
49
100
  def validate_uniqueness(components)
50
101
  msg = "Invalid signature. Duplicated component in signature input."
51
102
 
data/lib/linzer/ecdsa.rb CHANGED
@@ -1,18 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Linzer
4
+ # ECDSA (Elliptic Curve Digital Signature Algorithm) support.
5
+ #
6
+ # Supports P-256 (secp256r1/prime256v1) and P-384 (secp384r1) curves
7
+ # with SHA-256 and SHA-384 digests respectively.
8
+ #
9
+ # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-3.3.3 RFC 9421 Section 3.3.3
4
10
  module ECDSA
11
+ # ECDSA signing key implementation.
12
+ #
13
+ # ECDSA keys provide a good balance of security and performance.
14
+ # Supported algorithm identifiers:
15
+ # - `ecdsa-p256-sha256` - NIST P-256 curve with SHA-256
16
+ # - `ecdsa-p384-sha384` - NIST P-384 curve with SHA-384
17
+ #
18
+ # @note ECDSA signatures are converted between DER format (used by OpenSSL)
19
+ # and the concatenated r||s format required by RFC 9421.
20
+ #
21
+ # @example Generating a P-256 key
22
+ # key = Linzer.generate_ecdsa_p256_sha256_key("my-key")
23
+ #
24
+ # @example Loading from PEM
25
+ # key = Linzer.new_ecdsa_p256_sha256_key(File.read("ec_key.pem"), "key-1")
26
+ #
27
+ # @see Linzer::Key::Helper#generate_ecdsa_p256_sha256_key
28
+ # @see Linzer::Key::Helper#generate_ecdsa_p384_sha384_key
5
29
  class Key < Linzer::Key
30
+ # @api private
6
31
  def validate
7
32
  super
8
33
  validate_digest
9
34
  end
10
35
 
36
+ # Signs data using the ECDSA private key.
37
+ #
38
+ # The signature is returned in concatenated r||s format as required
39
+ # by RFC 9421, not in DER format.
40
+ #
41
+ # @param data [String] The data to sign
42
+ # @return [String] The signature bytes (64 bytes for P-256, 96 for P-384)
43
+ # @raise [SigningError] If this key does not contain private key material
11
44
  def sign(data)
12
45
  validate_signing_key
13
46
  decode_der_signature(material.sign(@params[:digest], data))
14
47
  end
15
48
 
49
+ # Verifies a signature using the ECDSA public key.
50
+ #
51
+ # Expects the signature in concatenated r||s format as specified
52
+ # by RFC 9421.
53
+ #
54
+ # @param signature [String] The signature bytes to verify
55
+ # @param data [String] The data that was signed
56
+ # @return [Boolean] true if the signature is valid, false otherwise
57
+ # @raise [VerifyError] If this key does not contain public key material
58
+ # @raise [Error] If the signature format is invalid
16
59
  def verify(signature, data)
17
60
  validate_verify_key
18
61
  material.verify(@params[:digest], der_signature(signature), data)
@@ -20,12 +63,17 @@ module Linzer
20
63
 
21
64
  private
22
65
 
66
+ # Mapping of digest algorithms to signature format parameters.
67
+ # hex_length is the total length of r||s in hex characters.
23
68
  DIGEST_PARAMS = {
24
69
  "SHA256" => {hex_format: "%.64x", hex_length: 64},
25
70
  "SHA384" => {hex_format: "%.96x", hex_length: 96}
26
71
  }
27
72
  private_constant :DIGEST_PARAMS
28
73
 
74
+ # Converts concatenated r||s format to DER for OpenSSL verification.
75
+ # @param sig [String] Signature in r||s format
76
+ # @return [String] DER-encoded signature
29
77
  def der_signature(sig)
30
78
  digest = @params[:digest]
31
79
  msg = "Cannot verify invalid signature."
@@ -47,6 +95,9 @@ module Linzer
47
95
  seq.to_der
48
96
  end
49
97
 
98
+ # Converts DER-encoded signature to concatenated r||s format.
99
+ # @param der_sig [String] DER-encoded signature from OpenSSL
100
+ # @return [String] Signature in r||s format
50
101
  def decode_der_signature(der_sig)
51
102
  digest = @params[:digest]
52
103
  msg = "Unsupported digest algorithm: '%s'" % digest
@@ -1,22 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Linzer
4
+ # Ed25519 elliptic curve signature algorithm support.
5
+ #
6
+ # Ed25519 is a modern, high-security signature algorithm that is fast
7
+ # and produces compact signatures. It's recommended for new applications
8
+ # where compatibility with older systems is not required.
9
+ #
10
+ # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-3.3.6 RFC 9421 Section 3.3.6
11
+ # @see https://ed25519.cr.yp.to/ Ed25519 specification
4
12
  module Ed25519
13
+ # Ed25519 signing key implementation.
14
+ #
15
+ # Ed25519 keys can be used for both signing (with private key) and
16
+ # verification (with public key). The algorithm identifier is `ed25519`.
17
+ #
18
+ # @example Generating a new key pair
19
+ # key = Linzer.generate_ed25519_key("my-key-id")
20
+ #
21
+ # @example Loading from PEM
22
+ # private_key = Linzer.new_ed25519_key(File.read("ed25519.pem"), "key-1")
23
+ # public_key = Linzer.new_ed25519_public_key(File.read("ed25519_pub.pem"), "key-1")
24
+ #
25
+ # @see Linzer::Key::Helper#generate_ed25519_key
26
+ # @see Linzer::Key::Helper#new_ed25519_key
5
27
  class Key < Linzer::Key
28
+ # Signs data using the Ed25519 private key.
29
+ #
30
+ # @param data [String] The data to sign (typically the signature base)
31
+ # @return [String] The 64-byte Ed25519 signature
32
+ # @raise [SigningError] If this key does not contain private key material
6
33
  def sign(data)
7
34
  validate_signing_key
8
35
  material.sign(nil, data)
9
36
  end
10
37
 
38
+ # Verifies a signature using the Ed25519 public key.
39
+ #
40
+ # @param signature [String] The signature bytes to verify
41
+ # @param data [String] The data that was signed
42
+ # @return [Boolean] true if the signature is valid, false otherwise
43
+ # @raise [VerifyError] If this key does not contain public key material
11
44
  def verify(signature, data)
12
45
  validate_verify_key
13
46
  material.verify(nil, signature, data)
14
47
  end
15
48
 
49
+ # @return [Boolean] true if this key contains public key material
16
50
  def public?
17
51
  has_pem_public?
18
52
  end
19
53
 
54
+ # @return [Boolean] true if this key contains private key material
20
55
  def private?
21
56
  has_pem_private?
22
57
  end
data/lib/linzer/helper.rb CHANGED
@@ -1,7 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Linzer
4
+ # Convenience methods for signing and verifying HTTP messages.
5
+ #
6
+ # These methods provide a simpler interface for common use cases,
7
+ # handling message wrapping and signature attachment automatically.
8
+ #
9
+ # @note These methods are mixed into the {Linzer} module and can be
10
+ # called directly as `Linzer.sign!` and `Linzer.verify!`.
4
11
  module Helper
12
+ # Signs an HTTP request or response and attaches the signature.
13
+ #
14
+ # This is a convenience method that wraps the message, creates a signature,
15
+ # and attaches it to the original HTTP message in one step.
16
+ #
17
+ # @param request_or_response [Net::HTTPRequest, Net::HTTPResponse, Rack::Request,
18
+ # Rack::Response, HTTP::Request] The HTTP message to sign
19
+ # @param args [Hash] Keyword arguments
20
+ # @option args [Linzer::Key] :key The private key to sign with (required)
21
+ # @option args [Array<String>] :components The components to include in the
22
+ # signature (required). Example: `%w[@method @path content-type]`
23
+ # @option args [String] :label Optional signature label (defaults to "sig1")
24
+ # @option args [Hash] :params Additional signature parameters (created, nonce, etc.)
25
+ #
26
+ # @return [Object] The original HTTP message with signature headers attached
27
+ #
28
+ # @raise [SigningError] If signing fails
29
+ # @raise [KeyError] If required arguments are missing
30
+ #
31
+ # @example Sign a Net::HTTP request
32
+ # request = Net::HTTP::Post.new(uri)
33
+ # request["content-type"] = "application/json"
34
+ # request["date"] = Time.now.httpdate
35
+ #
36
+ # Linzer.sign!(request,
37
+ # key: private_key,
38
+ # components: %w[@method @path content-type date]
39
+ # )
40
+ # # request now has "signature" and "signature-input" headers
41
+ #
42
+ # @example Sign with additional parameters
43
+ # Linzer.sign!(request,
44
+ # key: private_key,
45
+ # components: %w[@method @path],
46
+ # label: "my-sig",
47
+ # params: { nonce: SecureRandom.hex(16), tag: "my-app" }
48
+ # )
5
49
  def sign!(request_or_response, **args)
6
50
  message = Message.new(request_or_response)
7
51
  options = {}
@@ -15,6 +59,41 @@ module Linzer
15
59
  message.attach!(signature)
16
60
  end
17
61
 
62
+ # Verifies a signed HTTP request or response.
63
+ #
64
+ # Extracts the signature from the message headers, rebuilds the signature
65
+ # base, and verifies the cryptographic signature.
66
+ #
67
+ # @param request_or_response [Net::HTTPRequest, Net::HTTPResponse, Rack::Request,
68
+ # Rack::Response, HTTP::Request, HTTP::Response] The signed HTTP message
69
+ # @param key [Linzer::Key, nil] The public key to verify with. If nil,
70
+ # a block must be provided to look up the key.
71
+ # @param no_older_than [Integer] Maximum signature age in seconds.
72
+ # Defaults to 900 (15 minutes). Set to nil to disable age checking.
73
+ #
74
+ # @yield [keyid] Block to look up the verification key by keyid.
75
+ # Only called if `key` is nil.
76
+ # @yieldparam keyid [String] The key identifier from the signature
77
+ # @yieldreturn [Linzer::Key] The public key to use for verification
78
+ #
79
+ # @return [true] Returns true if verification succeeds
80
+ #
81
+ # @raise [VerifyError] If verification fails
82
+ # @raise [Error] If no key is provided and no keyid is in the signature
83
+ #
84
+ # @example Verify with a known key
85
+ # Linzer.verify!(request, key: public_key)
86
+ #
87
+ # @example Verify with key lookup
88
+ # Linzer.verify!(request) do |keyid|
89
+ # PublicKey.find_by(identifier: keyid).to_linzer_key
90
+ # end
91
+ #
92
+ # @example Verify with custom age limit (5 minutes)
93
+ # Linzer.verify!(request, key: public_key, no_older_than: 300)
94
+ #
95
+ # @example Verify without age checking
96
+ # Linzer.verify!(request, key: public_key, no_older_than: nil)
18
97
  def verify!(request_or_response, key: nil, no_older_than: 900)
19
98
  message = Message.new(request_or_response)
20
99
  signature_headers = {}
data/lib/linzer/hmac.rb CHANGED
@@ -3,29 +3,75 @@
3
3
  require "digest"
4
4
 
5
5
  module Linzer
6
+ # HMAC (Hash-based Message Authentication Code) symmetric signing support.
7
+ #
8
+ # HMAC uses a shared secret key for both signing and verification.
9
+ # This is useful when both parties can securely share a secret.
10
+ #
11
+ # @note HMAC keys are symmetric - the same key is used for signing and
12
+ # verification. Keep the key material secret!
13
+ #
14
+ # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-3.3.5 RFC 9421 Section 3.3.5
6
15
  module HMAC
16
+ # HMAC signing key implementation.
17
+ #
18
+ # HMAC-SHA256 is the primary supported algorithm, using the
19
+ # `hmac-sha256` algorithm identifier.
20
+ #
21
+ # @example Generating a new key
22
+ # key = Linzer.generate_hmac_sha256_key("shared-key")
23
+ #
24
+ # @example Using existing secret material
25
+ # secret = Base64.decode64(ENV["SIGNING_SECRET"])
26
+ # key = Linzer.new_hmac_sha256_key(secret, "api-key")
27
+ #
28
+ # @see Linzer::Key::Helper#generate_hmac_sha256_key
29
+ # @see Linzer::Key::Helper#new_hmac_sha256_key
7
30
  class Key < Linzer::Key
31
+ # @api private
8
32
  def validate
9
33
  super
10
34
  validate_digest
11
35
  end
12
36
 
37
+ # Signs data using HMAC.
38
+ #
39
+ # @param data [String] The data to sign
40
+ # @return [String] The HMAC digest (32 bytes for SHA-256)
13
41
  def sign(data)
14
42
  OpenSSL::HMAC.digest(@params[:digest], material, data)
15
43
  end
16
44
 
45
+ # Verifies an HMAC signature using constant-time comparison.
46
+ #
47
+ # Uses OpenSSL.secure_compare to prevent timing attacks where an
48
+ # attacker could measure response times to guess valid signatures.
49
+ #
50
+ # @param signature [String] The signature bytes to verify
51
+ # @param data [String] The data that was signed
52
+ # @return [Boolean] true if the signature is valid, false otherwise
17
53
  def verify(signature, data)
18
- signature == sign(data)
54
+ OpenSSL.secure_compare(signature, sign(data))
19
55
  end
20
56
 
57
+ # HMAC keys can always sign (they contain the secret).
58
+ # @return [Boolean] true if key material is present
21
59
  def private?
22
60
  !material.nil?
23
61
  end
24
62
 
63
+ # HMAC keys are symmetric, not public/private.
64
+ # @return [Boolean] always false for HMAC keys
25
65
  def public?
26
66
  false
27
67
  end
28
68
 
69
+ # Returns a safe string representation that doesn't leak the secret.
70
+ #
71
+ # The key material is intentionally excluded from the output to prevent
72
+ # accidental exposure in logs or error messages.
73
+ #
74
+ # @return [String] A string representation without the secret key
29
75
  def inspect
30
76
  vars =
31
77
  instance_variables
@@ -2,13 +2,24 @@
2
2
 
3
3
  module Linzer
4
4
  module HTTP
5
+ # Handles lazy loading of http.rb gem dependencies.
6
+ #
7
+ # The http.rb gem integration is optional and only loaded when
8
+ # explicitly required via `require "linzer/http/signature_feature"`.
9
+ #
10
+ # @api private
5
11
  module Bootstrap
6
12
  class << self
13
+ # Requires the http gem and related adapters.
14
+ # @api private
7
15
  def require_dependencies
8
16
  require "http"
9
17
  require_relative "../message/adapter/http_gem/request"
10
18
  end
11
19
 
20
+ # Loads dependencies, raising a helpful error if http gem is missing.
21
+ # @raise [Error] If the http gem is not installed
22
+ # @api private
12
23
  def load_dependencies
13
24
  require_dependencies
14
25
  rescue LoadError