invoance 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 +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE +21 -0
- data/README.md +198 -0
- data/lib/invoance/audit_canonical.rb +144 -0
- data/lib/invoance/audit_verify.rb +121 -0
- data/lib/invoance/client.rb +84 -0
- data/lib/invoance/config.rb +41 -0
- data/lib/invoance/errors.rb +104 -0
- data/lib/invoance/http.rb +174 -0
- data/lib/invoance/resources/attestations.rb +171 -0
- data/lib/invoance/resources/audit.rb +204 -0
- data/lib/invoance/resources/documents.rb +98 -0
- data/lib/invoance/resources/events.rb +71 -0
- data/lib/invoance/resources/traces.rb +60 -0
- data/lib/invoance/validate.rb +32 -0
- data/lib/invoance/version.rb +5 -0
- data/lib/invoance.rb +28 -0
- metadata +108 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 465e862ed29dc6dc0d5c4829472f739ddbaaace820845dd7c54bb6d9aabd77dc
|
|
4
|
+
data.tar.gz: 49e142ab37c383cd94b14aaa6f02a456e7a190bbdd961de45d9f4e215febe56c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6e26cf457a2f054a6d027c30a2fcb3b6335b220431de8d4b67541c6be824287a934ddc5b4509017602fc2949240489c7760fd85b4b024b00bbe609c82e43c078
|
|
7
|
+
data.tar.gz: ca148fd01bef837bc515093332b3908866a6ce6ebbae7ba02cc97bb84ca33e0869655d128681895d767a2b339ce130d302c55cb21b6c024423aadf64814400a1
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-07-06
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial release of the official Ruby SDK for the Invoance compliance API.
|
|
13
|
+
- `Invoance::Client` with resource accessors: `events`, `documents`,
|
|
14
|
+
`attestations`, `traces`, and `audit` (sub-resources `audit.events`,
|
|
15
|
+
`audit.orgs`, `audit.streams`, `audit.portal_sessions`, `audit.exports`).
|
|
16
|
+
- Synchronous HTTP transport built on Ruby stdlib (`net/http`) with zero runtime
|
|
17
|
+
gem dependencies.
|
|
18
|
+
- Full error hierarchy under `Invoance::Error` (`AuthenticationError`,
|
|
19
|
+
`ForbiddenError`, `NotFoundError`, `ValidationError`, `ConflictError`,
|
|
20
|
+
`QuotaExceededError`, `ServerError`, `NetworkError`, `TimeoutError`) with
|
|
21
|
+
status-code mapping and `Retry-After` parsing.
|
|
22
|
+
- Client-side input validation for SHA-256 hex digests.
|
|
23
|
+
- Offline audit-log verification: `invoance.audit/1` canonicalization
|
|
24
|
+
(`Invoance::AuditCanonical`) and Ed25519 signature verification
|
|
25
|
+
(`Invoance::AuditVerify`) using stdlib OpenSSL.
|
|
26
|
+
- `attestations.verify_signature` and `attestations.verify_payload` for
|
|
27
|
+
client-side AI-attestation verification.
|
|
28
|
+
- `Invoance::Resources.content_idempotency_key` for content-stable idempotency keys.
|
|
29
|
+
- `Client#validate` for a non-raising API-key/connectivity probe.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Invoance, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Invoance Ruby SDK
|
|
2
|
+
|
|
3
|
+
Official Ruby SDK for the [Invoance](https://invoance.com) compliance API —
|
|
4
|
+
cryptographic proof infrastructure for events, documents, AI attestations,
|
|
5
|
+
traces, and audit logs.
|
|
6
|
+
|
|
7
|
+
- **Synchronous** — methods return values and raise exceptions on error.
|
|
8
|
+
- **Zero runtime dependencies** — pure Ruby stdlib (`net/http`, `json`,
|
|
9
|
+
`openssl`, `digest`).
|
|
10
|
+
- **Offline verification** — check Ed25519 signatures and recompute audit-log
|
|
11
|
+
canonical hashes without trusting the server.
|
|
12
|
+
|
|
13
|
+
Requires **Ruby >= 3.0**.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Add to your Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem "invoance"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then:
|
|
24
|
+
|
|
25
|
+
```console
|
|
26
|
+
$ bundle install
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or install directly:
|
|
30
|
+
|
|
31
|
+
```console
|
|
32
|
+
$ gem install invoance
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
require "invoance"
|
|
39
|
+
|
|
40
|
+
# Reads INVOANCE_API_KEY and INVOANCE_BASE_URL from the environment.
|
|
41
|
+
client = Invoance::Client.new
|
|
42
|
+
|
|
43
|
+
# Or pass the key explicitly:
|
|
44
|
+
client = Invoance::Client.new(api_key: "inv_live_abc123")
|
|
45
|
+
|
|
46
|
+
# Ingest a compliance event
|
|
47
|
+
event = client.events.ingest(
|
|
48
|
+
event_type: "user.login",
|
|
49
|
+
payload: { "user_id" => "u_42" }
|
|
50
|
+
)
|
|
51
|
+
puts event["event_id"]
|
|
52
|
+
|
|
53
|
+
# Verify the API key / connectivity (never raises)
|
|
54
|
+
result = client.validate
|
|
55
|
+
raise "bad config: #{result["reason"]}" unless result["valid"]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Responses are plain Ruby `Hash`es with **string keys** matching the wire JSON
|
|
59
|
+
exactly — no symbolization.
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
client = Invoance::Client.new(
|
|
65
|
+
api_key: "inv_live_...", # or ENV["INVOANCE_API_KEY"]
|
|
66
|
+
base_url: "https://api.invoance.com", # or ENV["INVOANCE_BASE_URL"]
|
|
67
|
+
api_version: "v1",
|
|
68
|
+
timeout: 30, # seconds
|
|
69
|
+
idempotency_key: nil, # default for mutating requests
|
|
70
|
+
extra_headers: {} # merged into every request
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Resources
|
|
75
|
+
|
|
76
|
+
### Events
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
client.events.ingest(event_type:, payload:, event_time: nil, trace_id: nil, idempotency_key: nil)
|
|
80
|
+
client.events.list(page: nil, limit: nil, date_from: nil, date_to: nil, event_type: nil)
|
|
81
|
+
client.events.get(event_id)
|
|
82
|
+
client.events.verify(event_id, payload_hash: nil, payload: nil) # provide exactly one
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Documents
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
client.documents.anchor(document_hash:, document_ref: nil, event_type: nil,
|
|
89
|
+
original_bytes_b64: nil, metadata: nil, trace_id: nil,
|
|
90
|
+
idempotency_key: nil)
|
|
91
|
+
|
|
92
|
+
# Convenience: hash + base64 a file, then anchor.
|
|
93
|
+
client.documents.anchor_file(file: "./invoice.pdf", document_ref: "Invoice #1042")
|
|
94
|
+
# For raw bytes instead of a path:
|
|
95
|
+
client.documents.anchor_file(file: bytes, is_path: false, document_ref: "blob")
|
|
96
|
+
|
|
97
|
+
client.documents.list(...)
|
|
98
|
+
client.documents.get(event_id)
|
|
99
|
+
client.documents.get_original(event_id) # => raw binary String
|
|
100
|
+
client.documents.verify(event_id, document_hash:)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### AI Attestations
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
client.attestations.ingest(
|
|
107
|
+
type: "chat.completion",
|
|
108
|
+
input: { "prompt" => "..." },
|
|
109
|
+
output: { "text" => "..." },
|
|
110
|
+
model_provider: "openai",
|
|
111
|
+
model_name: "gpt-4o",
|
|
112
|
+
model_version: "2026-01",
|
|
113
|
+
subject: { user_id: "u_1", session_id: "s_9" } # optional
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
client.attestations.list(...)
|
|
117
|
+
client.attestations.get(id)
|
|
118
|
+
client.attestations.verify(id, content_hash:)
|
|
119
|
+
client.attestations.get_raw(id) # => Hash
|
|
120
|
+
|
|
121
|
+
# Client-side verification (no trust in the server):
|
|
122
|
+
client.attestations.verify_payload(id, raw_json_string_or_hash)
|
|
123
|
+
result = client.attestations.verify_signature(id)
|
|
124
|
+
result["valid"] # => true/false
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
> **Note:** for `verify_payload`, pass the raw JSON string exactly as shown in
|
|
128
|
+
> the dashboard's "Raw immutable record" viewer. Key order is preserved (not
|
|
129
|
+
> sorted) because the backend hashes with struct field order.
|
|
130
|
+
|
|
131
|
+
### Traces
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
trace = client.traces.create(label: "Batch 2026-07")
|
|
135
|
+
client.traces.list(status: "open")
|
|
136
|
+
client.traces.get(trace_id, event_page: 1, event_limit: 50)
|
|
137
|
+
client.traces.seal(trace_id)
|
|
138
|
+
client.traces.proof(trace_id) # => Hash
|
|
139
|
+
client.traces.proof_pdf(trace_id) # => raw binary PDF String
|
|
140
|
+
client.traces.delete(trace_id)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Audit Logs
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
client.audit.orgs.create(organization_id: "org_1", name: "Acme")
|
|
147
|
+
client.audit.events.ingest(
|
|
148
|
+
organization_id: "org_1",
|
|
149
|
+
action: "invoice.approved",
|
|
150
|
+
actor: { "type" => "user", "id" => "u_1" },
|
|
151
|
+
targets: [{ "type" => "invoice", "id" => "inv_9" }]
|
|
152
|
+
)
|
|
153
|
+
client.audit.events.list(organization_id: "org_1", limit: 100)
|
|
154
|
+
client.audit.streams.create("org_1", url: "https://siem.example/hook")
|
|
155
|
+
client.audit.portal_sessions.create(organization_id: "org_1", intent: "viewer")
|
|
156
|
+
client.audit.exports.create(organization_id: "org_1", format: "csv")
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Offline verify an audit event returned by the API:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
event = client.audit.events.get("evt_123")
|
|
163
|
+
result = Invoance::AuditVerify.verify_audit_event(event)
|
|
164
|
+
# => { "valid" => true, "reason" => nil, "payload_hash" => "...", "key_source" => "event" }
|
|
165
|
+
|
|
166
|
+
# Pin against the tenant's registered key for a real tamper guarantee:
|
|
167
|
+
Invoance::AuditVerify.verify_audit_event(event, public_key: registered_hex_key)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Error handling
|
|
171
|
+
|
|
172
|
+
Everything the SDK raises inherits from `Invoance::Error`:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
begin
|
|
176
|
+
client.events.ingest(event_type: "user.login", payload: {})
|
|
177
|
+
rescue Invoance::QuotaExceededError => e
|
|
178
|
+
puts "rate limited, retry after #{e.retry_after_seconds}s"
|
|
179
|
+
rescue Invoance::AuthenticationError
|
|
180
|
+
puts "check INVOANCE_API_KEY"
|
|
181
|
+
rescue Invoance::Error => e
|
|
182
|
+
puts "Invoance error: #{e.message} (status #{e.status_code})"
|
|
183
|
+
end
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Every error exposes `status_code`, `error_code`, `body`,
|
|
187
|
+
`retry_after_seconds`, and `request_context`.
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```console
|
|
192
|
+
$ bundle install
|
|
193
|
+
$ bundle exec rspec
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Invoance
|
|
7
|
+
# `invoance.audit/1` canonical serializer (client-side).
|
|
8
|
+
#
|
|
9
|
+
# Reproduces the server's frozen canonicalization so an event's signature
|
|
10
|
+
# can be checked offline. Canonical bytes = build the signed object
|
|
11
|
+
# (signed fields present + non-nil, timestamps normalized, forced
|
|
12
|
+
# schema_id), strip nil members recursively, sort every object's keys
|
|
13
|
+
# deeply (alphabetical), emit compact UTF-8.
|
|
14
|
+
#
|
|
15
|
+
# NOTE: Ruby's JSON.generate produces compact output, does NOT escape
|
|
16
|
+
# forward slashes, and emits UTF-8 literally — matching Node's
|
|
17
|
+
# JSON.stringify.
|
|
18
|
+
module AuditCanonical
|
|
19
|
+
AUDIT_SCHEMA_ID = "invoance.audit/1"
|
|
20
|
+
|
|
21
|
+
SIGNED_FIELDS = %w[
|
|
22
|
+
org_id event_id seq ingested_at action occurred_at actor targets context metadata
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
REQUIRED_FIELDS = %w[
|
|
26
|
+
org_id event_id seq ingested_at action occurred_at actor targets
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
RFC3339 = /\A(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|z|[+-]\d{2}:\d{2})\z/.freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# RFC3339 -> the one canonical form: UTC, exactly 3 fractional digits, `Z`.
|
|
34
|
+
# Fractional seconds are TRUNCATED to millis (not rounded).
|
|
35
|
+
#
|
|
36
|
+
# @param value [String]
|
|
37
|
+
# @return [String]
|
|
38
|
+
def normalize_ts(value)
|
|
39
|
+
raise ArgumentError, "timestamp must be a string" unless value.is_a?(String)
|
|
40
|
+
|
|
41
|
+
m = RFC3339.match(value.strip)
|
|
42
|
+
raise ArgumentError, "invalid RFC3339 timestamp: #{value}" unless m
|
|
43
|
+
|
|
44
|
+
yr, mo, dy, hh, mi, ss, frac, off = m.captures
|
|
45
|
+
millis = ((frac || "") + "000")[0, 3].to_i
|
|
46
|
+
|
|
47
|
+
# Build UTC epoch (in milliseconds) manually — no rounding.
|
|
48
|
+
base = Time.utc(yr.to_i, mo.to_i, dy.to_i, hh.to_i, mi.to_i, ss.to_i)
|
|
49
|
+
epoch_ms = (base.to_i * 1000) + millis
|
|
50
|
+
|
|
51
|
+
unless off == "Z" || off == "z"
|
|
52
|
+
sign = off[0] == "+" ? 1 : -1
|
|
53
|
+
oh = off[1, 2].to_i
|
|
54
|
+
om = off[4, 2].to_i
|
|
55
|
+
epoch_ms -= sign * (oh * 3600 + om * 60) * 1000
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
secs, ms = epoch_ms.divmod(1000)
|
|
59
|
+
d = Time.at(secs).utc
|
|
60
|
+
format("%04d-%02d-%02dT%02d:%02d:%02d.%03dZ",
|
|
61
|
+
d.year, d.month, d.day, d.hour, d.min, d.sec, ms)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @api private
|
|
65
|
+
def strip_nils(v)
|
|
66
|
+
case v
|
|
67
|
+
when Array
|
|
68
|
+
v.map { |x| strip_nils(x) }
|
|
69
|
+
when Hash
|
|
70
|
+
out = {}
|
|
71
|
+
v.each do |k, val|
|
|
72
|
+
next if val.nil?
|
|
73
|
+
|
|
74
|
+
out[k] = strip_nils(val)
|
|
75
|
+
end
|
|
76
|
+
out
|
|
77
|
+
else
|
|
78
|
+
v
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @api private
|
|
83
|
+
def sort_deep(v)
|
|
84
|
+
case v
|
|
85
|
+
when Array
|
|
86
|
+
v.map { |x| sort_deep(x) }
|
|
87
|
+
when Hash
|
|
88
|
+
v.keys.map(&:to_s).sort.each_with_object({}) do |k, out|
|
|
89
|
+
# Keys may be symbols or strings on the input; normalize lookup.
|
|
90
|
+
orig = v.key?(k) ? k : k.to_sym
|
|
91
|
+
out[k] = sort_deep(v[orig])
|
|
92
|
+
end
|
|
93
|
+
else
|
|
94
|
+
v
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @api private
|
|
99
|
+
def build_signed_object(event)
|
|
100
|
+
unless event.is_a?(Hash)
|
|
101
|
+
raise ArgumentError, "event must be a JSON object"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
e = stringify_keys(event)
|
|
105
|
+
REQUIRED_FIELDS.each do |f|
|
|
106
|
+
if e[f].nil?
|
|
107
|
+
raise ArgumentError, "missing required field: #{f}"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
out = {}
|
|
112
|
+
SIGNED_FIELDS.each do |f|
|
|
113
|
+
val = e[f]
|
|
114
|
+
next if val.nil?
|
|
115
|
+
|
|
116
|
+
out[f] = (f == "occurred_at" || f == "ingested_at") ? normalize_ts(val) : val
|
|
117
|
+
end
|
|
118
|
+
out["schema_id"] = AUDIT_SCHEMA_ID
|
|
119
|
+
out
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The canonical signed bytes for an audit event (binary String).
|
|
123
|
+
#
|
|
124
|
+
# @param event [Hash]
|
|
125
|
+
# @return [String] compact UTF-8 JSON
|
|
126
|
+
def canonical_audit_bytes(event)
|
|
127
|
+
signed = sort_deep(strip_nils(build_signed_object(event)))
|
|
128
|
+
JSON.generate(signed)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# payload_hash = SHA-256(canonical bytes), lowercase hex.
|
|
132
|
+
#
|
|
133
|
+
# @param canonical [String]
|
|
134
|
+
# @return [String]
|
|
135
|
+
def payload_hash_hex(canonical)
|
|
136
|
+
Digest::SHA256.hexdigest(canonical)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @api private
|
|
140
|
+
def stringify_keys(hash)
|
|
141
|
+
hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
require_relative "audit_canonical"
|
|
7
|
+
|
|
8
|
+
module Invoance
|
|
9
|
+
# Offline, client-side signature verification for audit events.
|
|
10
|
+
#
|
|
11
|
+
# Reconstructs the canonical signed bytes from an event returned by the API
|
|
12
|
+
# and checks the Ed25519 signature using stdlib OpenSSL (no external
|
|
13
|
+
# dependency, matching this SDK's zero-dependency design).
|
|
14
|
+
#
|
|
15
|
+
# Trust note: by default this verifies against the key embedded in the event
|
|
16
|
+
# (event["signing_public_key"]), which proves the payload is internally
|
|
17
|
+
# consistent with that key. For a real tamper guarantee, pass public_key =
|
|
18
|
+
# the tenant's registered key (the server pins it and never trusts the row's
|
|
19
|
+
# key).
|
|
20
|
+
#
|
|
21
|
+
# Ed25519 requires Ruby's OpenSSL to support Ed25519 (Ruby >= 3.0 with a
|
|
22
|
+
# modern OpenSSL). On older stacks the verify degrades to an invalid result
|
|
23
|
+
# with a descriptive reason rather than crashing. If you need to support
|
|
24
|
+
# such stacks, the `ed25519` gem is a drop-in alternative — but this SDK
|
|
25
|
+
# stays dependency-free.
|
|
26
|
+
module AuditVerify
|
|
27
|
+
# DER SPKI prefix for a raw 32-byte Ed25519 public key (RFC 8410).
|
|
28
|
+
SPKI_ED25519_PREFIX = ["302a300506032b6570032100"].pack("H*").freeze
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# Ed25519-verify a raw message with a raw 32-byte public key.
|
|
33
|
+
# Returns true/false; never raises (OpenSSL errors → false).
|
|
34
|
+
#
|
|
35
|
+
# @param message [String] binary message bytes
|
|
36
|
+
# @param signature [String] binary signature bytes
|
|
37
|
+
# @param pubkey [String] raw 32-byte public key
|
|
38
|
+
# @return [Boolean]
|
|
39
|
+
def ed25519_verify(message, signature, pubkey)
|
|
40
|
+
der = SPKI_ED25519_PREFIX + pubkey.b
|
|
41
|
+
pkey = OpenSSL::PKey.read(der)
|
|
42
|
+
# nil digest for Ed25519 (single-shot, no separate hash).
|
|
43
|
+
pkey.verify(nil, signature.b, message.b)
|
|
44
|
+
rescue StandardError, OpenSSL::OpenSSLError
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Hex-decode a hex string into a binary String.
|
|
49
|
+
# @api private
|
|
50
|
+
def hex_to_bytes(hex)
|
|
51
|
+
[hex].pack("H*")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Verify one audit event's signature offline.
|
|
55
|
+
#
|
|
56
|
+
# @param event [Hash] string-keyed event as returned by the API
|
|
57
|
+
# @param public_key [String, nil] hex-encoded pinned key to verify against
|
|
58
|
+
# @return [Hash] { "valid" =>, "reason" =>, "payload_hash" =>, "key_source" => }
|
|
59
|
+
def verify_audit_event(event, public_key: nil)
|
|
60
|
+
key_source = public_key.nil? ? "event" : "pinned"
|
|
61
|
+
e = stringify(event)
|
|
62
|
+
|
|
63
|
+
signed_input = {
|
|
64
|
+
"org_id" => e["org_id"],
|
|
65
|
+
"event_id" => (e["id"].nil? ? e["event_id"] : e["id"]),
|
|
66
|
+
"seq" => e["seq"],
|
|
67
|
+
"ingested_at" => e["ingested_at"],
|
|
68
|
+
"action" => e["action"],
|
|
69
|
+
"occurred_at" => e["occurred_at"],
|
|
70
|
+
"actor" => e["actor"],
|
|
71
|
+
"targets" => e["targets"]
|
|
72
|
+
}
|
|
73
|
+
signed_input["context"] = e["context"] unless e["context"].nil?
|
|
74
|
+
signed_input["metadata"] = e["metadata"] unless e["metadata"].nil?
|
|
75
|
+
|
|
76
|
+
begin
|
|
77
|
+
canonical = AuditCanonical.canonical_audit_bytes(signed_input)
|
|
78
|
+
rescue StandardError
|
|
79
|
+
return result(false, "canonicalization_failed", "", key_source)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
recomputed = AuditCanonical.payload_hash_hex(canonical)
|
|
83
|
+
if !e["payload_hash"].nil? && e["payload_hash"] != recomputed
|
|
84
|
+
return result(false, "payload_hash_mismatch", recomputed, key_source)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
key = public_key.nil? ? e["signing_public_key"] : public_key
|
|
88
|
+
return result(false, "no_public_key", recomputed, key_source) if key.nil? || key == ""
|
|
89
|
+
|
|
90
|
+
sig = e["signature"]
|
|
91
|
+
return result(false, "no_signature", recomputed, key_source) if sig.nil? || sig == ""
|
|
92
|
+
|
|
93
|
+
sig_bytes = sig.is_a?(String) ? hex_to_bytes(sig) : sig
|
|
94
|
+
key_bytes = key.is_a?(String) ? hex_to_bytes(key) : key
|
|
95
|
+
ok = ed25519_verify(canonical, sig_bytes, key_bytes)
|
|
96
|
+
|
|
97
|
+
if ok
|
|
98
|
+
result(true, nil, recomputed, key_source)
|
|
99
|
+
else
|
|
100
|
+
result(false, "signature_invalid", recomputed, key_source)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @api private
|
|
105
|
+
def result(valid, reason, payload_hash, key_source)
|
|
106
|
+
{
|
|
107
|
+
"valid" => valid,
|
|
108
|
+
"reason" => reason,
|
|
109
|
+
"payload_hash" => payload_hash,
|
|
110
|
+
"key_source" => key_source
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @api private
|
|
115
|
+
def stringify(hash)
|
|
116
|
+
return {} unless hash.is_a?(Hash)
|
|
117
|
+
|
|
118
|
+
hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "http"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "resources/events"
|
|
7
|
+
require_relative "resources/documents"
|
|
8
|
+
require_relative "resources/attestations"
|
|
9
|
+
require_relative "resources/traces"
|
|
10
|
+
require_relative "resources/audit"
|
|
11
|
+
|
|
12
|
+
module Invoance
|
|
13
|
+
# Top-level SDK client.
|
|
14
|
+
#
|
|
15
|
+
# # Reads INVOANCE_API_KEY / INVOANCE_BASE_URL from env automatically
|
|
16
|
+
# client = Invoance::Client.new
|
|
17
|
+
#
|
|
18
|
+
# # Or pass explicitly to override
|
|
19
|
+
# client = Invoance::Client.new(api_key: "inv_live_abc123")
|
|
20
|
+
#
|
|
21
|
+
# event = client.events.ingest(event_type: "user.login", payload: { user_id: "u_42" })
|
|
22
|
+
class Client
|
|
23
|
+
attr_reader :events, :documents, :attestations, :traces, :audit
|
|
24
|
+
|
|
25
|
+
# @param api_key [String, nil]
|
|
26
|
+
# @param base_url [String, nil]
|
|
27
|
+
# @param api_version [String]
|
|
28
|
+
# @param timeout [Numeric] seconds
|
|
29
|
+
# @param idempotency_key [String, nil]
|
|
30
|
+
# @param extra_headers [Hash]
|
|
31
|
+
def initialize(api_key: nil, base_url: nil, api_version: "v1", timeout: 30,
|
|
32
|
+
idempotency_key: nil, extra_headers: {})
|
|
33
|
+
@config = Config.new(
|
|
34
|
+
api_key: api_key,
|
|
35
|
+
base_url: base_url,
|
|
36
|
+
api_version: api_version,
|
|
37
|
+
timeout: timeout,
|
|
38
|
+
idempotency_key: idempotency_key,
|
|
39
|
+
extra_headers: extra_headers
|
|
40
|
+
)
|
|
41
|
+
@http = Http.new(@config)
|
|
42
|
+
|
|
43
|
+
@events = Resources::Events.new(@http)
|
|
44
|
+
@documents = Resources::Documents.new(@http)
|
|
45
|
+
@attestations = Resources::Attestations.new(@http)
|
|
46
|
+
@traces = Resources::Traces.new(@http)
|
|
47
|
+
@audit = Resources::Audit.new(@http)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The resolved base URL in use.
|
|
51
|
+
# @return [String]
|
|
52
|
+
def base_url
|
|
53
|
+
@config.base_url
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Probe a cheap authenticated endpoint (GET /v1/events?limit=1) to
|
|
57
|
+
# confirm the API key works. NEVER raises.
|
|
58
|
+
#
|
|
59
|
+
# @return [Hash] { "valid" =>, "reason" =>, "base_url" => }
|
|
60
|
+
def validate
|
|
61
|
+
base = @config.base_url
|
|
62
|
+
begin
|
|
63
|
+
@events.list(limit: 1)
|
|
64
|
+
{ "valid" => true, "reason" => nil, "base_url" => base }
|
|
65
|
+
rescue AuthenticationError
|
|
66
|
+
{ "valid" => false,
|
|
67
|
+
"reason" => "Authentication failed — check INVOANCE_API_KEY",
|
|
68
|
+
"base_url" => base }
|
|
69
|
+
rescue ForbiddenError
|
|
70
|
+
{ "valid" => true,
|
|
71
|
+
"reason" => "API key authenticated but lacks permission to list events",
|
|
72
|
+
"base_url" => base }
|
|
73
|
+
rescue QuotaExceededError
|
|
74
|
+
{ "valid" => true,
|
|
75
|
+
"reason" => "API key authenticated but currently rate limited",
|
|
76
|
+
"base_url" => base }
|
|
77
|
+
rescue NetworkError, TimeoutError => e
|
|
78
|
+
{ "valid" => false, "reason" => "Server unreachable: #{e.message}", "base_url" => base }
|
|
79
|
+
rescue Error => e
|
|
80
|
+
{ "valid" => false, "reason" => e.message, "base_url" => base }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Invoance
|
|
4
|
+
# Resolved, immutable SDK configuration.
|
|
5
|
+
#
|
|
6
|
+
# All fields are optional at the client boundary — when omitted the SDK
|
|
7
|
+
# reads from environment variables:
|
|
8
|
+
#
|
|
9
|
+
# * INVOANCE_API_KEY — API key (required)
|
|
10
|
+
# * INVOANCE_BASE_URL — API host (defaults to https://api.invoance.com)
|
|
11
|
+
class Config
|
|
12
|
+
DEFAULT_BASE_URL = "https://api.invoance.com"
|
|
13
|
+
DEFAULT_TIMEOUT = 30
|
|
14
|
+
ENV_API_KEY = "INVOANCE_API_KEY"
|
|
15
|
+
ENV_BASE_URL = "INVOANCE_BASE_URL"
|
|
16
|
+
|
|
17
|
+
attr_reader :api_key, :base_url, :api_version, :timeout, :idempotency_key, :extra_headers
|
|
18
|
+
|
|
19
|
+
# @param api_key [String, nil]
|
|
20
|
+
# @param base_url [String, nil]
|
|
21
|
+
# @param api_version [String]
|
|
22
|
+
# @param timeout [Numeric] request timeout in seconds
|
|
23
|
+
# @param idempotency_key [String, nil] default idempotency key for mutating requests
|
|
24
|
+
# @param extra_headers [Hash] merged into every request
|
|
25
|
+
def initialize(api_key: nil, base_url: nil, api_version: "v1", timeout: DEFAULT_TIMEOUT,
|
|
26
|
+
idempotency_key: nil, extra_headers: {})
|
|
27
|
+
resolved_key = api_key || ENV[ENV_API_KEY] || ""
|
|
28
|
+
if resolved_key.empty?
|
|
29
|
+
raise ArgumentError,
|
|
30
|
+
"api_key is required. Pass it explicitly or set the #{ENV_API_KEY} environment variable."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@api_key = resolved_key
|
|
34
|
+
@base_url = (base_url || ENV[ENV_BASE_URL] || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
|
|
35
|
+
@api_version = (api_version || "v1").gsub(%r{\A/+|/+\z}, "")
|
|
36
|
+
@timeout = timeout || DEFAULT_TIMEOUT
|
|
37
|
+
@idempotency_key = idempotency_key
|
|
38
|
+
@extra_headers = extra_headers || {}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|