age.rb 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6135f621a6b58de2582dea23188562bc6c5203c5218ebf4ac52fa034c743680f
4
+ data.tar.gz: 0a0fb8788f8aee9f39fa25dc74f9fb907ae2e4e1d213bdd47b28b0cba4b44c30
5
+ SHA512:
6
+ metadata.gz: 4458de0a38dcdb52bba5ee03a31ba873f867fa731df40c94d780f510a712f217c8d5a413f7cb78796fb24e1d6873d325164fb5756a716d4dc01004f127f8302c
7
+ data.tar.gz: d4435de3d279c8bb24c38635237e3627e1b6eb7b4c9f471e0ce3e37cd9d50d5ab45a9af8f29fecb674deeab0c33b1d736db86195c3cdafcc8d314f6dcaa316df
data/ext/Makefile ADDED
@@ -0,0 +1,22 @@
1
+ .PHONY: all
2
+ all: vet fmt lint build
3
+
4
+ .PHONY: vet
5
+ vet:
6
+ go vet ./...
7
+
8
+ .PHONY: fmt
9
+ fmt:
10
+ go fmt ./...
11
+
12
+ .PHONY: lint
13
+ lint:
14
+ golangci-lint run ./...
15
+
16
+ .PHONY: build
17
+ build:
18
+ go build -o ../age.so -buildmode=c-shared age.go
19
+
20
+ .PHONY: clean
21
+ clean:
22
+ rm -f ../age.so ../age.h
data/ext/age.go ADDED
@@ -0,0 +1,232 @@
1
+ package main
2
+
3
+ /*
4
+ #include <stdlib.h>
5
+
6
+ typedef struct {
7
+ char* data;
8
+ int length;
9
+ } AgeInput;
10
+
11
+ typedef struct {
12
+ char** data;
13
+ int* length;
14
+ } AgeOutput;
15
+
16
+ typedef struct {
17
+ char** pubkey;
18
+ char** privkey;
19
+ } AgeKeyPair;
20
+ */
21
+ import "C"
22
+
23
+ import (
24
+ "bytes"
25
+ "io"
26
+ "strings"
27
+ "unsafe"
28
+
29
+ "filippo.io/age"
30
+ "filippo.io/age/agessh"
31
+ "filippo.io/age/armor"
32
+ )
33
+
34
+ func __performEncryption(input *C.AgeInput, output *C.AgeOutput, armored C.int, recipients ...age.Recipient) *C.char {
35
+ var buffer bytes.Buffer
36
+ var writer io.Writer = &buffer
37
+ var armorWriter io.WriteCloser
38
+
39
+ if armored != 0 {
40
+ armorWriter = armor.NewWriter(writer)
41
+ writer = armorWriter
42
+ }
43
+
44
+ encryptor, err := age.Encrypt(writer, recipients...)
45
+ if err != nil {
46
+ return C.CString("Error during encryption setup: " + err.Error())
47
+ }
48
+
49
+ plainBytes := C.GoBytes(unsafe.Pointer(input.data), C.int(input.length))
50
+ if _, err := encryptor.Write(plainBytes); err != nil {
51
+ return C.CString("Error during encryption: " + err.Error())
52
+ }
53
+
54
+ if err := encryptor.Close(); err != nil {
55
+ return C.CString("Error closing encryption stream: " + err.Error())
56
+ }
57
+
58
+ if armorWriter != nil {
59
+ if err := armorWriter.Close(); err != nil {
60
+ return C.CString("Error closing armor writer: " + err.Error())
61
+ }
62
+ }
63
+
64
+ bytes := buffer.Bytes()
65
+ *output.length = C.int(len(bytes))
66
+ *output.data = (*C.char)(C.CBytes(bytes))
67
+
68
+ return nil
69
+ }
70
+
71
+ func __performDecryption(input *C.AgeInput, output *C.AgeOutput, armored C.int, identities ...age.Identity) *C.char {
72
+ encryptedBytes := C.GoBytes(unsafe.Pointer(input.data), C.int(input.length))
73
+ var reader io.Reader = bytes.NewReader(encryptedBytes)
74
+
75
+ if armored != 0 {
76
+ reader = armor.NewReader(reader)
77
+ }
78
+
79
+ decryptor, err := age.Decrypt(reader, identities...)
80
+ if err != nil {
81
+ return C.CString("Error during decryption setup: " + err.Error())
82
+ }
83
+
84
+ var buffer bytes.Buffer
85
+ if _, err = buffer.ReadFrom(decryptor); err != nil {
86
+ return C.CString("Error during decryption: " + err.Error())
87
+ }
88
+
89
+ plainBytes := buffer.Bytes()
90
+ *output.length = C.int(len(plainBytes))
91
+ *output.data = (*C.char)(C.CBytes(plainBytes))
92
+
93
+ return nil
94
+ }
95
+
96
+ //export encrypt
97
+ func encrypt(pubKeyStrs *C.char, input *C.AgeInput, output *C.AgeOutput, armored C.int) *C.char {
98
+ pubKeyStrList := strings.Split(C.GoString(pubKeyStrs), ",")
99
+ recipients := []age.Recipient{}
100
+
101
+ for _, pubKeyStr := range pubKeyStrList {
102
+ var pubKey age.Recipient
103
+ var err error
104
+
105
+ if strings.HasPrefix(pubKeyStr, "age1pq1") {
106
+ pubKey, err = age.ParseHybridRecipient(pubKeyStr)
107
+ if err != nil {
108
+ return C.CString("Error parsing hybrid public key: " + err.Error())
109
+ }
110
+ } else {
111
+ pubKey, err = age.ParseX25519Recipient(pubKeyStr)
112
+ if err != nil {
113
+ return C.CString("Error parsing public key: " + err.Error())
114
+ }
115
+ }
116
+ recipients = append(recipients, pubKey)
117
+ }
118
+
119
+ return __performEncryption(input, output, armored, recipients...)
120
+ }
121
+
122
+ //export decrypt
123
+ func decrypt(privKeyStrs *C.char, input *C.AgeInput, output *C.AgeOutput, armored C.int) *C.char {
124
+ privKeyStrList := strings.Split(C.GoString(privKeyStrs), ",")
125
+ identities := []age.Identity{}
126
+
127
+ for _, privKeyStr := range privKeyStrList {
128
+ var privKey age.Identity
129
+ var err error
130
+
131
+ if strings.HasPrefix(privKeyStr, "AGE-SECRET-KEY-PQ") {
132
+ privKey, err = age.ParseHybridIdentity(privKeyStr)
133
+ if err != nil {
134
+ return C.CString("Error parsing hybrid private key: " + err.Error())
135
+ }
136
+ } else {
137
+ privKey, err = age.ParseX25519Identity(privKeyStr)
138
+ if err != nil {
139
+ return C.CString("Error parsing private key: " + err.Error())
140
+ }
141
+ }
142
+ identities = append(identities, privKey)
143
+ }
144
+
145
+ return __performDecryption(input, output, armored, identities...)
146
+ }
147
+
148
+ //export generate_keypair
149
+ func generate_keypair(keypair *C.AgeKeyPair, pq C.int) *C.char {
150
+ var pubKey string
151
+ var privKey string
152
+
153
+ if pq != 0 {
154
+ identity, err := age.GenerateHybridIdentity()
155
+ if err != nil {
156
+ return C.CString("Error generating hybrid keypair: " + err.Error())
157
+ }
158
+ pubKey = identity.Recipient().String()
159
+ privKey = identity.String()
160
+ } else {
161
+ identity, err := age.GenerateX25519Identity()
162
+ if err != nil {
163
+ return C.CString("Error generating keypair: " + err.Error())
164
+ }
165
+ pubKey = identity.Recipient().String()
166
+ privKey = identity.String()
167
+ }
168
+
169
+ *keypair.pubkey = C.CString(pubKey)
170
+ *keypair.privkey = C.CString(privKey)
171
+
172
+ return nil
173
+ }
174
+
175
+ //export encrypt_with_passphrase
176
+ func encrypt_with_passphrase(passphrase *C.char, input *C.AgeInput, output *C.AgeOutput, armored C.int) *C.char {
177
+ recipient, err := age.NewScryptRecipient(C.GoString(passphrase))
178
+ if err != nil {
179
+ return C.CString("Error creating scrypt recipient: " + err.Error())
180
+ }
181
+
182
+ return __performEncryption(input, output, armored, recipient)
183
+ }
184
+
185
+ //export decrypt_with_passphrase
186
+ func decrypt_with_passphrase(passphrase *C.char, input *C.AgeInput, output *C.AgeOutput, armored C.int) *C.char {
187
+ identity, err := age.NewScryptIdentity(C.GoString(passphrase))
188
+ if err != nil {
189
+ return C.CString("Error creating scrypt identity: " + err.Error())
190
+ }
191
+
192
+ return __performDecryption(input, output, armored, identity)
193
+ }
194
+
195
+ //export encrypt_with_ssh_keys
196
+ func encrypt_with_ssh_keys(sshPubKeyStrs *C.char, input *C.AgeInput, output *C.AgeOutput, armored C.int) *C.char {
197
+ sshPubKeyStrList := strings.Split(C.GoString(sshPubKeyStrs), ",")
198
+ recipients := []age.Recipient{}
199
+
200
+ for _, sshPubKeyStr := range sshPubKeyStrList {
201
+ recipient, err := agessh.ParseRecipient(sshPubKeyStr)
202
+ if err != nil {
203
+ return C.CString("Error parsing SSH public key: " + err.Error())
204
+ }
205
+ recipients = append(recipients, recipient)
206
+ }
207
+
208
+ return __performEncryption(input, output, armored, recipients...)
209
+ }
210
+
211
+ //export decrypt_with_ssh_keys
212
+ func decrypt_with_ssh_keys(sshPrivKeyStrs *C.char, input *C.AgeInput, output *C.AgeOutput, armored C.int) *C.char {
213
+ sshPrivKeyStrList := strings.Split(C.GoString(sshPrivKeyStrs), ",")
214
+ identities := []age.Identity{}
215
+
216
+ for _, sshPrivKeyStr := range sshPrivKeyStrList {
217
+ identity, err := agessh.ParseIdentity([]byte(sshPrivKeyStr))
218
+ if err != nil {
219
+ return C.CString("Error parsing SSH private key: " + err.Error())
220
+ }
221
+ identities = append(identities, identity)
222
+ }
223
+
224
+ return __performDecryption(input, output, armored, identities...)
225
+ }
226
+
227
+ //export free_memory
228
+ func free_memory(ptr *C.char) {
229
+ C.free(unsafe.Pointer(ptr))
230
+ }
231
+
232
+ func main() {}
data/ext/extconf.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mkmf'
4
+
5
+ # Check if Go is installed
6
+ go_bin = find_executable('go')
7
+ abort 'Go is required to build this gem. Please install Go from https://golang.org/dl/' unless go_bin
8
+
9
+ # Build the CGO shared library
10
+ puts 'Building CGO shared library...'
11
+ Dir.chdir(__dir__) do
12
+ abort 'age.go not found in ext directory. The gem source files may be corrupted.' unless File.exist?('age.go')
13
+
14
+ output_path = File.join('..', 'age.so')
15
+ unless system(go_bin, 'build', '-o', output_path, '-buildmode=c-shared', 'age.go')
16
+ abort 'Failed to build CGO shared library. Ensure Go is properly installed and dependencies are available.'
17
+ end
18
+ end
19
+
20
+ # Create a dummy Makefile since we've already built the library
21
+ File.write('Makefile', <<~MAKEFILE)
22
+ .PHONY: install clean
23
+
24
+ install:
25
+ \t@echo "Shared library already built"
26
+
27
+ clean:
28
+ \t@echo "Nothing to clean"
29
+ MAKEFILE
data/ext/go.mod ADDED
@@ -0,0 +1,12 @@
1
+ module github.com/tschaefer/ruby-age
2
+
3
+ go 1.25.3
4
+
5
+ require filippo.io/age v1.3.1
6
+
7
+ require (
8
+ filippo.io/edwards25519 v1.1.0 // indirect
9
+ filippo.io/hpke v0.4.0 // indirect
10
+ golang.org/x/crypto v0.45.0 // indirect
11
+ golang.org/x/sys v0.38.0 // indirect
12
+ )
data/ext/go.sum ADDED
@@ -0,0 +1,14 @@
1
+ c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
2
+ c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
3
+ filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0=
4
+ filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4=
5
+ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
6
+ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
7
+ filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
8
+ filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
9
+ golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
10
+ golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
11
+ golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
12
+ golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
13
+ golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
14
+ golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ffi'
4
+
5
+ module Age
6
+ ##
7
+ # FFI bindings for the age cgo library.
8
+ module Bindings
9
+ extend FFI::Library
10
+
11
+ ffi_lib File.expand_path('../../age.so', __dir__)
12
+
13
+ ##
14
+ # Struct for input data to encryption/decryption functions.
15
+ class AgeInput < FFI::Struct
16
+ layout :data, :pointer,
17
+ :length, :int
18
+ end
19
+
20
+ ##
21
+ # Struct for output data from encryption/decryption functions.
22
+ class AgeOutput < FFI::Struct
23
+ layout :data, :pointer,
24
+ :length, :pointer
25
+ end
26
+
27
+ ##
28
+ # Struct for age key pairs.
29
+ class AgeKeyPair < FFI::Struct
30
+ layout :public_key, :pointer,
31
+ :private_key, :pointer
32
+ end
33
+
34
+ attach_function :encrypt, [:string, AgeInput.by_ref, AgeOutput.by_ref, :int], :pointer
35
+ attach_function :decrypt, [:string, AgeInput.by_ref, AgeOutput.by_ref, :int], :pointer
36
+ attach_function :encrypt_with_passphrase, [:string, AgeInput.by_ref, AgeOutput.by_ref, :int], :pointer
37
+ attach_function :decrypt_with_passphrase, [:string, AgeInput.by_ref, AgeOutput.by_ref, :int], :pointer
38
+ attach_function :encrypt_with_ssh_keys, [:string, AgeInput.by_ref, AgeOutput.by_ref, :int], :pointer
39
+ attach_function :decrypt_with_ssh_keys, [:string, AgeInput.by_ref, AgeOutput.by_ref, :int], :pointer
40
+ attach_function :generate_keypair, [AgeKeyPair.by_ref, :int], :pointer
41
+ attach_function :free_memory, [:pointer], :void
42
+ end
43
+ end
data/lib/age/errors.rb ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Age
4
+ ##
5
+ # Custom error class for encryption errors.
6
+ class EncryptionError < StandardError
7
+ ##
8
+ # @return [String, nil] reason for the encryption error
9
+ attr_reader :reason
10
+
11
+ def initialize(reason = nil)
12
+ @reason = reason
13
+ super('Encryption failed.')
14
+ end
15
+ end
16
+
17
+ ##
18
+ # Custom error class for decryption errors.
19
+ class DecryptionError < StandardError
20
+ ##
21
+ # @return [String, nil] reason for the decryption error
22
+ attr_reader :reason
23
+
24
+ def initialize(reason = nil)
25
+ @reason = reason
26
+ super('Decryption failed.')
27
+ end
28
+ end
29
+
30
+ ##
31
+ # Custom error class for generate key pair errors.
32
+ class GenerateKeyPairError < StandardError
33
+ ##
34
+ # @return [String, nil] reason for the key pair generation error
35
+ attr_reader :reason
36
+
37
+ def initialize(reason = nil)
38
+ @reason = reason
39
+ super('Generation key pair failed.')
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Age
4
+ VERSION = '0.1.0'
5
+ end
data/lib/age.rb ADDED
@@ -0,0 +1,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ffi'
4
+
5
+ require 'age/bindings'
6
+ require 'age/errors'
7
+ require 'age/version'
8
+
9
+ ##
10
+ # Ruby bindings for [age](https://github.com/FiloSottile/age) using a CGO shared
11
+ # library with FFI bindings.
12
+ #
13
+ # Age is a simple, modern, and secure file encryption tool, format, and Go
14
+ # library. This gem provides a Ruby interface to age's encryption and decryption
15
+ # capabilities.
16
+ #
17
+ # Features:
18
+ #
19
+ # - Encrypt and decrypt data using age public/private key pairs
20
+ # - Encrypt and decrypt files directly
21
+ # - Generate age keypairs programmatically
22
+ # - Multiple recipients support for encryption
23
+ # - ASCII armor format support for text-safe encrypted output
24
+ # - FFI-based integration with Go's age implementation
25
+ # - Binary data handling with proper encoding
26
+ module Age
27
+ class << self
28
+ ##
29
+ # Encrypts plain data using the provided age public keys.
30
+ #
31
+ # @param pubkeys [Array<String>] List of age public keys.
32
+ # @param plain [Bytes] Plain data to encrypt.
33
+ # @param armor [Boolean] Whether to armor the output.
34
+ #
35
+ # @return [Bytes] Encrypted data.
36
+ def encrypt(pubkeys, plain, armor: false)
37
+ pubkeys = pubkeys.join(',') if pubkeys.is_a?(Array)
38
+ perform_encryption(plain) do |input, output|
39
+ Age::Bindings.encrypt(pubkeys, input, output, armor ? 1 : 0)
40
+ end
41
+ end
42
+
43
+ ## Encrypts plain data using the provided passphrase.
44
+ #
45
+ # @param passphrase [String] Passphrase to use for encryption.
46
+ # @param plain [Bytes] Plain data to encrypt.
47
+ # @param armor [Boolean] Whether to armor the output.
48
+ #
49
+ # @return [Bytes] Encrypted data.
50
+ def encrypt_with_passphrase(passphrase, plain, armor: false)
51
+ perform_encryption(plain) do |input, output|
52
+ Age::Bindings.encrypt_with_passphrase(passphrase, input, output, armor ? 1 : 0)
53
+ end
54
+ end
55
+
56
+ ##
57
+ # Encrypts plain data using the provided SSH public keys.
58
+ #
59
+ # @param ssh_pubkeys [Array<String>] List of SSH public keys (ssh-rsa, ssh-ed25519).
60
+ # @param plain [Bytes] Plain data to encrypt.
61
+ # @param armor [Boolean] Whether to armor the output.
62
+ #
63
+ # @return [Bytes] Encrypted data.
64
+ def encrypt_with_ssh_keys(ssh_pubkeys, plain, armor: false)
65
+ ssh_pubkeys = ssh_pubkeys.join(',') if ssh_pubkeys.is_a?(Array)
66
+ perform_encryption(plain) do |input, output|
67
+ Age::Bindings.encrypt_with_ssh_keys(ssh_pubkeys, input, output, armor ? 1 : 0)
68
+ end
69
+ end
70
+
71
+ ##
72
+ # Decrypts encrypted data using the provided age private keys.
73
+ #
74
+ # @param privkeys [Array<String>] List of age private keys.
75
+ # @param encrypted [Bytes] Encrypted data to decrypt.
76
+ # @param armor [Boolean] Whether the input is armored.
77
+ #
78
+ # @return [Bytes] Decrypted plain data.
79
+ def decrypt(privkeys, encrypted, armor: false)
80
+ privkeys = privkeys.join(',') if privkeys.is_a?(Array)
81
+ perform_decryption(encrypted) do |input, output|
82
+ Age::Bindings.decrypt(privkeys, input, output, armor ? 1 : 0)
83
+ end
84
+ end
85
+
86
+ ##
87
+ # Decrypts encrypted data using the provided passphrase.
88
+ #
89
+ # @param passphrase [String] Passphrase to use for decryption.
90
+ # @param encrypted [Bytes] Encrypted data to decrypt.
91
+ # @param armor [Boolean] Whether the input is armored.
92
+ #
93
+ # @return [Bytes] Decrypted plain data.
94
+ def decrypt_with_passphrase(passphrase, encrypted, armor: false)
95
+ perform_decryption(encrypted) do |input, output|
96
+ Age::Bindings.decrypt_with_passphrase(passphrase, input, output, armor ? 1 : 0)
97
+ end
98
+ end
99
+
100
+ ##
101
+ # Decrypts encrypted data using the provided SSH private keys.
102
+ #
103
+ # @param ssh_privkeys [Array<String>] List of SSH private keys (ssh-rsa, ssh-ed25519).
104
+ # @param encrypted [Bytes] Encrypted data to decrypt.
105
+ # @param armor [Boolean] Whether the input is armored.
106
+ #
107
+ # @return [Bytes] Decrypted plain data.
108
+ def decrypt_with_ssh_keys(ssh_privkeys, encrypted, armor: false)
109
+ ssh_privkeys = ssh_privkeys.join(',') if ssh_privkeys.is_a?(Array)
110
+ perform_decryption(encrypted) do |input, output|
111
+ Age::Bindings.decrypt_with_ssh_keys(ssh_privkeys, input, output, armor ? 1 : 0)
112
+ end
113
+ end
114
+
115
+ ## Encrypts a file using the provided age public keys.
116
+ #
117
+ # @param pubkeys [Array<String>] List of age public keys.
118
+ # @param infile [String] Input file path.
119
+ # @param outfile [String, nil] Output file path. If nil, appends `.age` to infile.
120
+ # @param armor [Boolean] Whether to armor the output.
121
+ #
122
+ # @return [void]
123
+ def encrypt_file(pubkeys, infile, outfile = nil, armor: false)
124
+ perform_file_encryption(infile, outfile) do |plain|
125
+ encrypt(pubkeys, plain, armor:)
126
+ end
127
+ end
128
+
129
+ ##
130
+ # Encrypts a file using the provided passphrase.
131
+ #
132
+ # @param passphrase [String] Passphrase to use for encryption.
133
+ # @param infile [String] Input file path.
134
+ # @param outfile [String, nil] Output file path. If nil, appends `.age` to infile.
135
+ # @param armor [Boolean] Whether to armor the output.
136
+ #
137
+ # @return [void]
138
+ def encrypt_file_with_passphrase(passphrase, infile, outfile = nil, armor: false)
139
+ perform_file_encryption(infile, outfile) do |plain|
140
+ encrypt_with_passphrase(passphrase, plain, armor:)
141
+ end
142
+ end
143
+
144
+ ##
145
+ # Encrypts a file using the provided SSH public keys.
146
+ #
147
+ # @param ssh_pubkeys [Array<String>] List of SSH public keys (ssh-rsa, ssh-ed25519).
148
+ # @param infile [String] Input file path.
149
+ # @param outfile [String, nil] Output file path. If nil, appends `.age` to infile.
150
+ # @param armor [Boolean] Whether to armor the output.
151
+ #
152
+ # @return [void]
153
+ def encrypt_file_with_ssh_keys(ssh_pubkeys, infile, outfile = nil, armor: false)
154
+ perform_file_encryption(infile, outfile) do |plain|
155
+ encrypt_with_ssh_keys(ssh_pubkeys, plain, armor:)
156
+ end
157
+ end
158
+
159
+ ## Decrypts a file using the provided age private keys.
160
+ #
161
+ # @param privkeys [Array<String>] List of age private keys.
162
+ # @param infile [String] Input file path.
163
+ # @param outfile [String, nil] Output file path. If nil, removes `.age` from infile.
164
+ #
165
+ # @return [void]
166
+ def decrypt_file(privkeys, infile, outfile = nil)
167
+ perform_file_decryption(infile, outfile) do |encrypted, armor|
168
+ decrypt(privkeys, encrypted, armor:)
169
+ end
170
+ end
171
+
172
+ ##
173
+ # Decrypts a file using the provided passphrase.
174
+ #
175
+ # @param passphrase [String] Passphrase to use for decryption.
176
+ # @param infile [String] Input file path.
177
+ # @param outfile [String, nil] Output file path. If nil, removes `.age` from infile.
178
+ #
179
+ # @return [void]
180
+ def decrypt_file_with_passphrase(passphrase, infile, outfile = nil)
181
+ perform_file_decryption(infile, outfile) do |encrypted, armor|
182
+ decrypt_with_passphrase(passphrase, encrypted, armor:)
183
+ end
184
+ end
185
+
186
+ ##
187
+ # Decrypts a file using the provided SSH private keys.
188
+ #
189
+ # @param ssh_privkeys [Array<String>] List of SSH private keys (ssh-rsa, ssh-ed25519).
190
+ # @param infile [String] Input file path.
191
+ # @param outfile [String, nil] Output file path. If nil, removes `.age` from infile.
192
+ #
193
+ # @return [void]
194
+ def decrypt_file_with_ssh_keys(ssh_privkeys, infile, outfile = nil)
195
+ perform_file_decryption(infile, outfile) do |encrypted, armor|
196
+ decrypt_with_ssh_keys(ssh_privkeys, encrypted, armor:)
197
+ end
198
+ end
199
+
200
+ ##
201
+ # Generates a new age key pair.
202
+ #
203
+ # @param postquantum [Boolean] Whether to generate a post-quantum key pair.
204
+ #
205
+ # @return [Hash{Symbol => String}] A hash containing :public_key and :private_key.
206
+ def generate_keypair(postquantum: false)
207
+ pubkey_ptr = FFI::MemoryPointer.new(:pointer)
208
+ privkey_ptr = FFI::MemoryPointer.new(:pointer)
209
+ keypair = Age::Bindings::AgeKeyPair.new
210
+ keypair[:public_key] = pubkey_ptr
211
+ keypair[:private_key] = privkey_ptr
212
+
213
+ err_ptr = Age::Bindings.generate_keypair(keypair, postquantum ? 1 : 0)
214
+ unless err_ptr.null?
215
+ err_msg = read_string_from_pointer(err_ptr)
216
+ raise GenerateKeyPairError, err_msg
217
+ end
218
+
219
+ pubkey = read_string_from_pointer(pubkey_ptr.read_pointer)
220
+ privkey = read_string_from_pointer(privkey_ptr.read_pointer)
221
+
222
+ { public_key: pubkey, private_key: privkey }
223
+ end
224
+
225
+ ##
226
+ # Performs encryption operation with common FFI setup.
227
+ #
228
+ # @param plain [Bytes] Plain data to encrypt.
229
+ # @yield [input, output] Block that performs the actual encryption call.
230
+ #
231
+ # @return [Bytes] Encrypted data.
232
+ def perform_encryption(plain)
233
+ plain = plain.b if plain.respond_to?(:b)
234
+
235
+ plain_ptr = FFI::MemoryPointer.new(:char, plain.bytesize)
236
+ plain_ptr.put_bytes(0, plain)
237
+
238
+ input = Age::Bindings::AgeInput.new
239
+ input[:data] = plain_ptr
240
+ input[:length] = plain.bytesize
241
+
242
+ encrypted_ptr = FFI::MemoryPointer.new(:pointer)
243
+ encrypted_len_ptr = FFI::MemoryPointer.new(:int)
244
+
245
+ output = Age::Bindings::AgeOutput.new
246
+ output[:data] = encrypted_ptr
247
+ output[:length] = encrypted_len_ptr
248
+
249
+ err_ptr = yield(input, output)
250
+ unless err_ptr.null?
251
+ err_msg = read_string_from_pointer(err_ptr)
252
+ raise EncryptionError, err_msg
253
+ end
254
+
255
+ bytes = read_bytes_from_pointer(encrypted_ptr.read_pointer, encrypted_len_ptr.read_int)
256
+ bytes.force_encoding('BINARY')
257
+ end
258
+
259
+ ##
260
+ # Performs decryption operation with common FFI setup.
261
+ #
262
+ # @param encrypted [Bytes] Encrypted data to decrypt.
263
+ # @yield [input, output] Block that performs the actual decryption call.
264
+ #
265
+ # @return [Bytes] Decrypted plain data.
266
+ def perform_decryption(encrypted)
267
+ encrypted = encrypted.b if encrypted.respond_to?(:b)
268
+
269
+ encrypted_ptr = FFI::MemoryPointer.new(:char, encrypted.bytesize)
270
+ encrypted_ptr.put_bytes(0, encrypted)
271
+
272
+ input = Age::Bindings::AgeInput.new
273
+ input[:data] = encrypted_ptr
274
+ input[:length] = encrypted.bytesize
275
+
276
+ plain_ptr = FFI::MemoryPointer.new(:pointer)
277
+ plain_len_ptr = FFI::MemoryPointer.new(:int)
278
+
279
+ output = Age::Bindings::AgeOutput.new
280
+ output[:data] = plain_ptr
281
+ output[:length] = plain_len_ptr
282
+
283
+ err_ptr = yield(input, output)
284
+ unless err_ptr.null?
285
+ err_msg = read_string_from_pointer(err_ptr)
286
+ raise DecryptionError, err_msg
287
+ end
288
+
289
+ bytes = read_bytes_from_pointer(plain_ptr.read_pointer, plain_len_ptr.read_int)
290
+ bytes.force_encoding('BINARY')
291
+ end
292
+
293
+ ##
294
+ # Performs file encryption with common file handling.
295
+ #
296
+ # @param infile [String] Input file path.
297
+ # @param outfile [String, nil] Output file path.
298
+ # @yield [plain] Block that performs the actual encryption.
299
+ #
300
+ # @return [void]
301
+ def perform_file_encryption(infile, outfile)
302
+ outfile ||= "#{infile}.age"
303
+ plain = File.binread(infile)
304
+ encrypted = yield(plain)
305
+ File.binwrite(outfile, encrypted)
306
+ end
307
+
308
+ ##
309
+ # Performs file decryption with common file handling and armor detection.
310
+ #
311
+ # @param infile [String] Input file path.
312
+ # @param outfile [String, nil] Output file path.
313
+ # @yield [encrypted, armor] Block that performs the actual decryption.
314
+ #
315
+ # @return [void]
316
+ def perform_file_decryption(infile, outfile)
317
+ outfile ||= File.basename(infile, '.*')
318
+ encrypted = File.binread(infile)
319
+ armor = detect_armor_format?(encrypted)
320
+ plain = yield(encrypted, armor)
321
+ File.binwrite(outfile, plain)
322
+ end
323
+
324
+ ##
325
+ # Detects if the encrypted data is in armor format.
326
+ #
327
+ # @param encrypted [String] Encrypted data.
328
+ #
329
+ # @return [Boolean] True if armored, false otherwise.
330
+ def detect_armor_format?(encrypted)
331
+ encrypted.start_with?('-----BEGIN AGE ENCRYPTED FILE-----') &&
332
+ encrypted.end_with?("-----END AGE ENCRYPTED FILE-----\n")
333
+ end
334
+
335
+ ##
336
+ # Reads a string from a pointer and frees the memory.
337
+ #
338
+ # @param ptr [FFI::Pointer] Pointer to the string.
339
+ #
340
+ # @return [String, nil] The string read from the pointer, or nil.
341
+ def read_string_from_pointer(ptr)
342
+ return nil if ptr.null?
343
+
344
+ str = ptr.read_string
345
+ Age::Bindings.free_memory(ptr)
346
+
347
+ str
348
+ end
349
+
350
+ ##
351
+ # Reads bytes from a pointer and frees the memory.
352
+ #
353
+ # @param ptr [FFI::Pointer] Pointer to the bytes.
354
+ # @param length [Integer] Length of the bytes to read.
355
+ #
356
+ # @return [String, nil] The bytes read from the pointer, or nil.
357
+ def read_bytes_from_pointer(ptr, length)
358
+ return nil if ptr.null? || length.zero?
359
+
360
+ bytes = ptr.read_bytes(length)
361
+ Age::Bindings.free_memory(ptr)
362
+
363
+ bytes
364
+ end
365
+
366
+ private :perform_encryption, :perform_decryption, :perform_file_encryption,
367
+ :perform_file_decryption, :detect_armor_format?,
368
+ :read_string_from_pointer, :read_bytes_from_pointer
369
+ end
370
+ end
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: age.rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tobias Schäfer
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ffi
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.17'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.17'
26
+ description: 'age.rb: Ruby bindings for age
27
+
28
+ '
29
+ email:
30
+ - github@blackox.org
31
+ executables: []
32
+ extensions:
33
+ - ext/extconf.rb
34
+ extra_rdoc_files: []
35
+ files:
36
+ - ext/Makefile
37
+ - ext/age.go
38
+ - ext/extconf.rb
39
+ - ext/go.mod
40
+ - ext/go.sum
41
+ - lib/age.rb
42
+ - lib/age/bindings.rb
43
+ - lib/age/errors.rb
44
+ - lib/age/version.rb
45
+ homepage: https://github.com/tschaefer/age.rb
46
+ licenses:
47
+ - BSD-3-Clause
48
+ metadata:
49
+ rubygems_mfa_required: 'true'
50
+ source_code_uri: https://github.com/tschaefer/age.rb
51
+ bug_tracker_uri: https://github.com/tschaefer/age.rb/issues
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: 3.2.3
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 4.0.3
67
+ specification_version: 4
68
+ summary: 'age.rb: Ruby bindings for age'
69
+ test_files: []