linzer 0.7.7 → 0.7.8
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/CHANGELOG.md +5 -0
- data/LICENSE.txt +1 -1
- data/README.md +3 -1
- data/flake.lock +109 -0
- data/flake.nix +73 -0
- data/lib/linzer/common.rb +51 -0
- data/lib/linzer/ecdsa.rb +51 -0
- data/lib/linzer/ed25519.rb +35 -0
- data/lib/linzer/helper.rb +79 -0
- data/lib/linzer/hmac.rb +47 -1
- data/lib/linzer/http/bootstrap.rb +11 -0
- data/lib/linzer/http/signature_feature.rb +53 -1
- data/lib/linzer/http.rb +54 -0
- data/lib/linzer/jws.rb +74 -0
- data/lib/linzer/key/helper.rb +186 -10
- data/lib/linzer/key.rb +73 -0
- data/lib/linzer/message/adapter/abstract.rb +75 -10
- data/lib/linzer/message/adapter/generic/request.rb +27 -0
- data/lib/linzer/message/adapter/generic/response.rb +17 -0
- data/lib/linzer/message/adapter/http_gem/request.rb +11 -0
- data/lib/linzer/message/adapter/http_gem/response.rb +8 -5
- data/lib/linzer/message/adapter/net_http/request.rb +7 -0
- data/lib/linzer/message/adapter/net_http/response.rb +4 -0
- data/lib/linzer/message/adapter/rack/common.rb +14 -6
- data/lib/linzer/message/adapter/rack/request.rb +13 -0
- data/lib/linzer/message/adapter/rack/response.rb +11 -0
- data/lib/linzer/message/adapter.rb +17 -0
- data/lib/linzer/message/field/parser.rb +14 -0
- data/lib/linzer/message/field.rb +32 -2
- data/lib/linzer/message/wrapper.rb +20 -0
- data/lib/linzer/message.rb +113 -3
- data/lib/linzer/options.rb +13 -0
- data/lib/linzer/rsa.rb +34 -0
- data/lib/linzer/rsa_pss.rb +44 -0
- data/lib/linzer/signature.rb +113 -1
- data/lib/linzer/signer.rb +69 -0
- data/lib/linzer/verifier.rb +52 -0
- data/lib/linzer/version.rb +3 -1
- data/lib/linzer.rb +104 -0
- data/lib/rack/auth/signature.rb +90 -6
- metadata +30 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '002232186b5d47054f7f531537caf71c250774b56e09be381e967f770fdfec71'
|
|
4
|
+
data.tar.gz: 5c40887f1e5ba4e9695acecc011fc12c470268e35e8a4edc38076fb318d075f8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '08fc04ddd98910aadf1d7538ce29db51882960ee33a27e88030d29520bccac3f0cf06757560ef90de27849eaf8a3bfff7e713e478c5cf5d68bb975c38661c4e9'
|
|
7
|
+
data.tar.gz: 4b0a9b295522e9cf9736ad8db7b12ba9d010fc4c97f023f1cf0a6bf4ea7e8bea23430d1131af66dba04e6b195ee47ff45f4624618355c3553c7238e913fc4f70
|
data/CHANGELOG.md
CHANGED
data/LICENSE.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
The MIT License (MIT)
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2024-
|
|
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
|
data/lib/linzer/ed25519.rb
CHANGED
|
@@ -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
|
|
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
|