shojiku 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 707c37a88e032ed04277d749ea4247cf14da6f6f0de05027201d59b227ecdb51
4
- data.tar.gz: aa9468c6655f56945089db380531d2557434cda0c0e6f064d0fb562cccd9ceb4
3
+ metadata.gz: 3b481d059f220a867ef40b179f1144342866f85f742e6efec0b7c6a6ee153207
4
+ data.tar.gz: 0b9644e8961d4c0f759929b7d0dbd7d3bb76415fe63d5efd7d65f20a1899be1b
5
5
  SHA512:
6
- metadata.gz: e78ce31fa9fa5049477ab3a4af7a357fccc319bdd61e57ab52fa6054142a700a45c113126c31eefac98aca67ad4aa2dd2cb3b649adbb7cba97e004afee2713a6
7
- data.tar.gz: a1d82a98e85e251c34cead95ab6e3d70c3fb2e6e19cd0e7ee493823f9d6b5e57b9cf9f64a5439e39af113b73e3eb649163a83042772a304319a17a8051e4d66f
6
+ metadata.gz: e2220169bdc93028f743bcfe407a3bcfb8f830ee3a8b9a1109bd121c76c76f07675583cbff1300b19be836305e202b6a455255d7643869c4dc01d806b3d80bde
7
+ data.tar.gz: 82a9ebddebaca0137c4b72a5c6ac858767f4f7a7f0b555a5288405f2cf0f23d8ce0ea07b5925d2a6af04b39878e2a26ab93f3f3b4d43417546815ddaffb31cea
data/README.md CHANGED
@@ -1,15 +1,9 @@
1
1
  # Shojiku for Ruby
2
2
 
3
- Ruby bindings for [Shojiku](../../README.md) — a document engine that
3
+ Ruby bindings for [Shojiku](https://shojiku.pages.dev) — a document engine that
4
4
  turns a YAML template plus your data into a deterministic PDF, signs it,
5
5
  and verifies it.
6
6
 
7
- > **Unreleased.** The gem is written and gated but not published yet —
8
- > all seven Shojiku SDKs publish together at v0.1.0. Until then, install
9
- > from a repository clone (see [Building the engine
10
- > library](#building-the-engine-library) below), or use the CLI or the
11
- > Docker image — see the [quickstart](../../docs/quickstart.md).
12
-
13
7
  ## Install
14
8
 
15
9
  ```bash
@@ -104,6 +98,39 @@ on a passing verdict as well as a failing one. A document whose
104
98
  signature does not verify comes back as a *failed* result that still
105
99
  carries the full report.
106
100
 
101
+ ### Signing with a key this process never holds
102
+
103
+ When the private key lives in a cloud KMS, an HSM or a smartcard, use
104
+ `ExternalSigner` instead. Shojiku hands out the bytes a signature has to
105
+ cover; your block signs them wherever the key is and hands the signature
106
+ back, so the key never enters your application:
107
+
108
+ ```ruby
109
+ provider = Shojiku::ExternalSigner.new(cert: "signer.crt",
110
+ algorithm: :ecdsa_p256_sha256) do |to_be_signed|
111
+ kms.sign(key_id: ENV.fetch("KEY_ID"), message: to_be_signed,
112
+ message_type: "RAW", signing_algorithm: "ECDSA_SHA_256").signature
113
+ end
114
+
115
+ signed = result.artifact.sign(provider)
116
+ ```
117
+
118
+ The call site is unchanged — which provider you pass is the only
119
+ difference, and a provider registered by name works the same way under
120
+ `strict:`. This gem ships no cloud client of its own: the block is
121
+ whichever client your application already uses.
122
+
123
+ Two details worth getting right. The bytes handed to the block are the
124
+ CMS **signed attributes**, not the document's digest — a service that
125
+ signs a digest must hash *these* bytes with SHA-256 itself. And the
126
+ signature is that operation's raw output: PKCS#1 v1.5 bytes for
127
+ `:rsa_pkcs1_sha256`, an ASN.1 DER sequence for `:ecdsa_p256_sha256`,
128
+ which is what AWS KMS and Google Cloud KMS both return unchanged.
129
+
130
+ Exceptions raised inside your block are *not* swallowed into a failed
131
+ result: an outage at your key service is not a fact about the document,
132
+ and `Result#failure?` would read as one.
133
+
107
134
  ### Results, not exceptions
108
135
 
109
136
  Nothing raises in the normal flow. Every operation returns a result you
@@ -282,7 +309,9 @@ Nothing in this gem downloads anything, at install time or at run time.
282
309
 
283
310
  ## Building the engine library
284
311
 
285
- Until the platform gems are published, build the library from a clone:
312
+ The platform gems carry the library, so this is only for a platform they
313
+ do not cover, or for working against an engine you changed. Build it from
314
+ a clone:
286
315
 
287
316
  ```bash
288
317
  make capi-lib
@@ -307,13 +336,15 @@ Ruby 3.3 or newer.
307
336
 
308
337
  ## Documentation
309
338
 
310
- - [Template reference](../../docs/engine/README.md) — how to write the
311
- YAML the engine renders
312
- - [SDK policy](../../docs/agents/sdk.md) — the lifecycle contract every
313
- Shojiku SDK implements
339
+ - [Template reference](https://github.com/kengos/shojiku/blob/main/docs/engine/README.md) —
340
+ how to write the YAML the engine renders
341
+ - [SDK policy](https://github.com/kengos/shojiku/blob/main/docs/agents/sdk.md) —
342
+ the lifecycle contract every Shojiku SDK implements
314
343
 
315
344
  ## License
316
345
 
317
- Licensed under any of [Apache-2.0](../../LICENSE-APACHE),
318
- [MIT](../../LICENSE-MIT), or [BSD-3-Clause](../../LICENSE-BSD), at your
319
- option.
346
+ Licensed under any of
347
+ [Apache-2.0](https://github.com/kengos/shojiku/blob/main/LICENSE-APACHE),
348
+ [MIT](https://github.com/kengos/shojiku/blob/main/LICENSE-MIT), or
349
+ [BSD-3-Clause](https://github.com/kengos/shojiku/blob/main/LICENSE-BSD),
350
+ at your option.
@@ -128,8 +128,11 @@ module Shojiku
128
128
  # Signs an artifact with `provider`, returning a {Result}. The signed
129
129
  # bytes begin with the input byte for byte — signing appends a revision.
130
130
  #
131
- # `provider` is a {LocalPem} (or another provider object), or the NAME of
132
- # one registered in configuration. A strict client takes the name only.
131
+ # `provider` is a {LocalPem} (a key in this process), an {ExternalSigner}
132
+ # (a key that never enters it a cloud KMS, an HSM, a smartcard), or the
133
+ # NAME of one registered in configuration. A strict client takes the name
134
+ # only. Which kind it is changes nothing here: the provider knows how to
135
+ # reach the engine for its own sort of key.
133
136
  def sign(artifact, provider)
134
137
  signer = @settings.lockdown.provider!(provider)
135
138
  @settings.lockdown.signable!(artifact)
@@ -177,10 +180,7 @@ module Shojiku
177
180
  # The signed document inherits the origin of what it signed: appending a
178
181
  # revision does not launder where the document came from.
179
182
  def signed(artifact, provider)
180
- snapshot = @engine.sign(
181
- pdf: artifact.bytes, key: provider.key, certificate: provider.certificate,
182
- passphrase: provider.passphrase
183
- )
183
+ snapshot = provider.sign_with(@engine, artifact.bytes)
184
184
  Outcome.document(snapshot, step: :sign, client: self, origin: artifact.origin)
185
185
  rescue MaterialUnreadable => e
186
186
  Result.failed(Failure.new(step: :sign, kind: e.kind, message: e.message))
@@ -33,46 +33,78 @@ module Shojiku
33
33
  INT32 = "l"
34
34
  SIZE = Fiddle::SIZEOF_SIZE_T == 8 ? "Q" : "L"
35
35
 
36
+ # Every data argument crosses as a (pointer, length) PAIR — nothing is
37
+ # NUL-terminated, because PDF bytes contain NUL — and every call ends with
38
+ # the out-slot the result handle is written to. Spelling the signatures as
39
+ # a table rather than one call each keeps them impossible to disagree
40
+ # with the header: an operation's arity is the count of its arguments.
41
+ PAIR = [VOIDP, SIZE_T].freeze
42
+ OUT = [VOIDP].freeze
43
+
36
44
  # Only the lifecycle the SDK contract defines is bound: engine info,
37
45
  # render, sign, verify. `validate` and `preview` are the authoring
38
46
  # surface's, not an artifact lifecycle's — the Designer reaches them
39
47
  # through the WASM bindings, and binding them here would be surface with
40
48
  # no contract behind it.
49
+ SIGNATURES = {
50
+ shojiku_engine_info: OUT,
51
+ shojiku_render: PAIR + OUT,
52
+ shojiku_sign: (PAIR * 4) + OUT,
53
+ shojiku_sign_prepare: (PAIR * 3) + OUT,
54
+ shojiku_sign_complete: (PAIR * 4) + OUT,
55
+ shojiku_verify: (PAIR * 2) + OUT
56
+ }.freeze
57
+
41
58
  def initialize(library)
42
59
  @library = library
43
- @info = library.function(:shojiku_engine_info, [VOIDP], INT)
44
- @render = library.function(:shojiku_render, [VOIDP, SIZE_T, VOIDP], INT)
45
- @sign = library.function(
46
- :shojiku_sign,
47
- [VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP], INT
48
- )
49
- @verify = library.function(:shojiku_verify, [VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP], INT)
60
+ @calls = SIGNATURES.to_h { |name, args| [name, library.function(name, args, INT)] }
50
61
  declare_accessors(library)
51
62
  end
52
63
 
53
64
  def engine_info
54
- invoke { |out| @info.call(out) }
65
+ invoke { |out| call(:shojiku_engine_info, out) }
55
66
  end
56
67
 
57
68
  def render(request)
58
- invoke { |out| @render.call(request, request.bytesize, out) }
69
+ invoke { |out| call(:shojiku_render, request, request.bytesize, out) }
59
70
  end
60
71
 
61
72
  def sign(pdf:, key:, certificate:, passphrase: nil)
62
73
  invoke do |out|
63
- @sign.call(
64
- pdf, pdf.bytesize, key, key.bytesize, certificate, certificate.bytesize,
65
- passphrase, passphrase ? passphrase.bytesize : 0, out
66
- )
74
+ call(:shojiku_sign, pdf, pdf.bytesize, key, key.bytesize,
75
+ certificate, certificate.bytesize,
76
+ passphrase, passphrase ? passphrase.bytesize : 0, out)
77
+ end
78
+ end
79
+
80
+ # The two halves of signing with a key held elsewhere. Both take the same
81
+ # document, certificate and algorithm — the pair is stateless, so the
82
+ # second call re-derives what the first prepared.
83
+ def sign_prepare(pdf:, certificate:, algorithm:)
84
+ invoke do |out|
85
+ call(:shojiku_sign_prepare, pdf, pdf.bytesize, certificate, certificate.bytesize,
86
+ algorithm, algorithm.bytesize, out)
87
+ end
88
+ end
89
+
90
+ def sign_complete(pdf:, certificate:, algorithm:, signature:)
91
+ invoke do |out|
92
+ call(:shojiku_sign_complete, pdf, pdf.bytesize, certificate, certificate.bytesize,
93
+ algorithm, algorithm.bytesize, signature, signature.bytesize, out)
67
94
  end
68
95
  end
69
96
 
70
97
  def verify(pdf:, anchors:)
71
- invoke { |out| @verify.call(pdf, pdf.bytesize, anchors, anchors.bytesize, out) }
98
+ invoke { |out| call(:shojiku_verify, pdf, pdf.bytesize, anchors, anchors.bytesize, out) }
72
99
  end
73
100
 
74
101
  private
75
102
 
103
+ # The one place a lifecycle call crosses.
104
+ def call(name, *)
105
+ @calls.fetch(name).call(*)
106
+ end
107
+
76
108
  def declare_accessors(library)
77
109
  buffers = %i[shojiku_result_pdf shojiku_result_json shojiku_result_diagnostics_json
78
110
  shojiku_result_error_json]
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shojiku
4
+ # A signing provider for a key this process is never given.
5
+ #
6
+ # The second provider, and the shape {LocalPem}'s own comment promised: a
7
+ # new class rather than new arguments on `sign`, so the call site is
8
+ # unchanged in all seven SDKs.
9
+ #
10
+ # The engine hands out the bytes a signature has to cover; the block signs
11
+ # them wherever the key actually lives — AWS KMS, Google Cloud KMS, an HSM,
12
+ # a smartcard, another service entirely — and hands the signature back:
13
+ #
14
+ # provider = Shojiku::ExternalSigner.new(cert: "signer.crt",
15
+ # algorithm: :ecdsa_p256_sha256) do |to_be_signed|
16
+ # kms.sign(key_id: ENV.fetch("KEY_ID"), message: to_be_signed,
17
+ # message_type: "RAW", signing_algorithm: "ECDSA_SHA_256").signature
18
+ # end
19
+ # client.sign(artifact, provider)
20
+ #
21
+ # Shojiku ships no cloud client of its own, deliberately: the block is
22
+ # whatever client your application already has, and the SDK stays a wrapper
23
+ # with nothing to keep in step with a vendor's releases.
24
+ #
25
+ # **What the block receives is the signed ATTRIBUTES, not the document
26
+ # digest.** A service that signs a digest must hash these bytes with SHA-256
27
+ # itself. Signing the document digest instead produces a document that fails
28
+ # verification, so the distinction is not cosmetic.
29
+ #
30
+ # The signature is the raw output of that operation: PKCS#1 v1.5 bytes for
31
+ # `:rsa_pkcs1_sha256`, an ASN.1 DER sequence for `:ecdsa_p256_sha256` —
32
+ # which is what both major cloud key services return unchanged.
33
+ #
34
+ # The certificate comes either from a path (`cert:`) or from bytes already
35
+ # in memory (`cert_pem:`), explicit rather than sniffed, exactly as
36
+ # {LocalPem} takes its material.
37
+ class ExternalSigner
38
+ FORMS = "`cert:` (a path) or `cert_pem:` (bytes)"
39
+
40
+ # The wire spellings the engine accepts, keyed by the Ruby-side names.
41
+ # A Symbol reads better at a call site; a String is accepted because
42
+ # configuration files produce them.
43
+ ALGORITHMS = {
44
+ rsa_pkcs1_sha256: "rsa-pkcs1-sha256",
45
+ ecdsa_p256_sha256: "ecdsa-p256-sha256"
46
+ }.freeze
47
+
48
+ attr_reader :algorithm
49
+
50
+ def initialize(cert: nil, cert_pem: nil, algorithm: nil, &block)
51
+ @cert_path = cert
52
+ @cert_pem = cert_pem
53
+ @algorithm = wire_algorithm(algorithm)
54
+ @block = block
55
+ one_source!(cert, cert_pem)
56
+ raise UsageError, "ExternalSigner needs a block that signs the bytes it is given" unless block
57
+ end
58
+
59
+ # Redacted for the same reason {LocalPem} is. Nothing here is key
60
+ # material — that is the point of this provider — but a block closes over
61
+ # whatever built it, which in practice is a client holding credentials.
62
+ def inspect
63
+ "#<#{self.class.name} cert=#{form} algorithm=#{@algorithm}>"
64
+ end
65
+
66
+ def certificate
67
+ @certificate ||= @cert_pem || Material.read(@cert_path, "certificate_unreadable")
68
+ end
69
+
70
+ # Signs `pdf` in two calls, with the block in between.
71
+ #
72
+ # Both engine calls take the same document, certificate and algorithm:
73
+ # the pair is stateless, so the second re-derives what the first prepared.
74
+ # Keeping them inside ONE method is what makes that impossible to get
75
+ # wrong from Ruby — there is no way to pair a prepare of one document
76
+ # with a complete of another.
77
+ #
78
+ # A prepare that did not succeed is returned as it is: an unreadable
79
+ # certificate or a document the signer refuses is a fact about the
80
+ # inputs, and paying for a signature afterwards would tell the caller
81
+ # nothing new.
82
+ def sign_with(engine, pdf)
83
+ prepared = engine.sign_prepare(pdf: pdf, certificate: certificate, algorithm: @algorithm)
84
+ return prepared unless prepared.status.zero? && prepared.success
85
+
86
+ engine.sign_complete(
87
+ pdf: pdf, certificate: certificate, algorithm: @algorithm,
88
+ signature: signature_for(prepared)
89
+ )
90
+ end
91
+
92
+ private
93
+
94
+ # Runs the block over the bytes the engine wants signed.
95
+ #
96
+ # The block's own exceptions are deliberately not rescued: it is the
97
+ # caller's code talking to the caller's key service, and turning its
98
+ # failures into a failed {Result} would file a caller's outage under
99
+ # "something was wrong with this document".
100
+ # `unpack1("m")` rather than `Base64.decode64`: base64 is a bundled gem
101
+ # now, and this SDK's runtime dependency list is exactly one entry
102
+ # (fiddle) on purpose. The unpack directive is core String, so decoding
103
+ # the payload costs nothing an application has to install.
104
+ def signature_for(prepared)
105
+ to_be_signed = JSON.parse(prepared.json).fetch("toBeSigned").unpack1("m")
106
+ signature = @block.call(to_be_signed)
107
+ unless signature.is_a?(String) && !signature.empty?
108
+ raise UsageError,
109
+ "the signer block must return the signature as a non-empty String of bytes"
110
+ end
111
+
112
+ signature.b
113
+ end
114
+
115
+ def wire_algorithm(algorithm)
116
+ raise UsageError, "ExternalSigner needs `algorithm:` (#{named})" if algorithm.nil?
117
+
118
+ ALGORITHMS.fetch(algorithm.to_sym) do
119
+ raise UsageError, "`algorithm:` must be one of #{named}"
120
+ end
121
+ end
122
+
123
+ def named
124
+ ALGORITHMS.keys.map(&:inspect).join(" or ")
125
+ end
126
+
127
+ # The path, or a note that the bytes came from memory — a configured path
128
+ # is not secret, and it is the one thing worth seeing when a provider
129
+ # loaded the wrong certificate.
130
+ def form
131
+ @cert_path ? @cert_path.to_s : "[pem bytes]"
132
+ end
133
+
134
+ # Explicit, never sniffed, in BOTH directions — {LocalPem}'s rule, for
135
+ # the same reason: guessing whether a string is a path or a PEM body is
136
+ # how the wrong file gets read.
137
+ def one_source!(path, pem)
138
+ raise UsageError, "ExternalSigner takes either #{FORMS}, not both" if path && pem
139
+ return if path || pem
140
+
141
+ raise UsageError, "ExternalSigner needs either #{FORMS}"
142
+ end
143
+ end
144
+ end
@@ -3,10 +3,10 @@
3
3
  module Shojiku
4
4
  # A signing provider backed by a PEM key and certificate.
5
5
  #
6
- # The only provider this release has. KMS and HSM providers are a recorded
7
- # deferral, which is why this is a named class rather than a pair of
8
- # arguments on `sign` a second provider then adds a class, not a signature
9
- # change in seven languages.
6
+ # For a key this process may hold. When it may not a cloud KMS, an HSM, a
7
+ # smartcard the provider is an {ExternalSigner} instead, which is why this
8
+ # is a named class rather than a pair of arguments on `sign`: the second
9
+ # provider added a class, not a signature change in seven languages.
10
10
  #
11
11
  # The material comes either from paths (`key:` / `cert:`) or from bytes
12
12
  # already in memory (`key_pem:` / `cert_pem:`), so a key fetched from a
@@ -53,6 +53,16 @@ module Shojiku
53
53
  @certificate ||= @cert_pem || Material.read(@cert_path, "certificate_unreadable")
54
54
  end
55
55
 
56
+ # Signs `pdf`, in one call, with the key this provider holds.
57
+ #
58
+ # Each provider knows how to reach the engine for its own kind of key, so
59
+ # the client asks the provider rather than branching on its class — the
60
+ # difference between a local key and one held elsewhere is two engine
61
+ # calls instead of one, and that belongs here.
62
+ def sign_with(engine, pdf)
63
+ engine.sign(pdf: pdf, key: key, certificate: certificate, passphrase: passphrase)
64
+ end
65
+
56
66
  private
57
67
 
58
68
  # The path, or a note that the bytes came from memory. A configured file
@@ -3,5 +3,5 @@
3
3
  module Shojiku
4
4
  # Tracks the engine workspace version. All seven SDKs move together while
5
5
  # everything is pre-1.0 and publish together at the first public release.
6
- VERSION = "0.1.0"
6
+ VERSION = "0.2.0"
7
7
  end
data/lib/shojiku.rb CHANGED
@@ -14,6 +14,7 @@ require_relative "shojiku/result"
14
14
  require_relative "shojiku/verification_report"
15
15
  require_relative "shojiku/artifact"
16
16
  require_relative "shojiku/local_pem"
17
+ require_relative "shojiku/external_signer"
17
18
  require_relative "shojiku/lockdown"
18
19
  require_relative "shojiku/sources"
19
20
  require_relative "shojiku/template_root"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shojiku
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kengos
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-02 00:00:00.000000000 Z
11
+ date: 2026-08-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: fiddle
@@ -45,6 +45,7 @@ files:
45
45
  - lib/shojiku/engine.rb
46
46
  - lib/shojiku/env.rb
47
47
  - lib/shojiku/errors.rb
48
+ - lib/shojiku/external_signer.rb
48
49
  - lib/shojiku/failure.rb
49
50
  - lib/shojiku/library.rb
50
51
  - lib/shojiku/local_pem.rb
@@ -58,13 +59,13 @@ files:
58
59
  - lib/shojiku/template_root.rb
59
60
  - lib/shojiku/verification_report.rb
60
61
  - lib/shojiku/version.rb
61
- homepage: https://github.com/kengos/shojiku
62
+ homepage: https://shojiku.pages.dev
62
63
  licenses:
63
64
  - MIT
64
65
  - Apache-2.0
65
66
  - BSD-3-Clause
66
67
  metadata:
67
- homepage_uri: https://github.com/kengos/shojiku
68
+ homepage_uri: https://shojiku.pages.dev
68
69
  source_code_uri: https://github.com/kengos/shojiku/tree/main/sdk/ruby
69
70
  documentation_uri: https://github.com/kengos/shojiku/blob/main/docs/engine/README.md
70
71
  rubygems_mfa_required: 'true'