opentimestamps 0.1.0 → 0.3.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: 6f146094f21ed3a732a3a7cfa6b799366a25a2434953d16dcf3825052f3c967a
4
- data.tar.gz: '08b95d3c6e8e7b3853e4753d76171e72eb46b9645c07454ebf6ea2b1407510db'
3
+ metadata.gz: 0b838c1eea0b1a967eca1038728e5aaa07aea6fc10ea932258543cda2d31c91a
4
+ data.tar.gz: '009ed17044167540849e8b07dde1f218a5bd4bd2e55dbf593ecfe07126fe1a54'
5
5
  SHA512:
6
- metadata.gz: 1d063990950ea15459b67adf25fdb8699dec5ff1ce75901cefdeee5c03b34373207b6a7ec988c1764cc19458576254cc0b8a06e796ea9ad98d282776c63d34c5
7
- data.tar.gz: 41756a3dd2934d2f236b43c5738524cf121a62f692c026144910aa8150525cad8aa2891585e1bb58ba62ea6b5a39f40c617c7d8d617b31dc98d02208b02bfe1e
6
+ metadata.gz: 91e9a62492b192bc54dcdc71c63d69c6964921f6a3003e9b5055b83444f60ef537bed2fac99100061e25800706c39fcae1e9cb337e72a39ae3337126e7e02bb3
7
+ data.tar.gz: 592987fab8b0eb07e7dd9165d2441df8352abb210170d0f19572a303fbc6686169208f8161114c663570f5c3694441982e5aef3e6774a201a1501f6eeb1d54f0
data/CHANGELOG.md CHANGED
@@ -5,6 +5,78 @@ project uses [Semantic Versioning](https://semver.org/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.3.0]
9
+
10
+ ### Added
11
+
12
+ - `OpenTimestamps.digest_file` streams a file through a file-hash op, so the `ots`
13
+ CLI no longer reads a whole file into memory to stamp or verify it.
14
+
15
+ ### Changed
16
+
17
+ - `verify` tolerates a chain oracle that is unreachable for some blocks: if the
18
+ reachable anchors already meet the quorum it succeeds; if a quorum is reachable
19
+ but blocked by the outage it raises `NetworkError` (carrying the underlying
20
+ cause) rather than `VerificationError`; and if the quorum is unreachable no
21
+ matter the oracle it still raises `VerificationError`. A transient outage is
22
+ never reported as a failed proof, and an impossible quorum is never reported as
23
+ an outage.
24
+ - A chain oracle now signals a nonexistent block with the new `BlockNotFound`
25
+ (HTTP 404 for the explorer, RPC -8/-5 for a node), which `verify` treats as "not
26
+ anchored" (a `VerificationError`), not as an outage.
27
+ - **Breaking for direct callers of `Timestamp#verify`:** each result now includes
28
+ an `error:` field, and `verified: false` can mean either a root mismatch or an
29
+ oracle that could not be reached (check `error`). The high-level `verify` /
30
+ `verified?` API is unchanged.
31
+
32
+ ### Hardened
33
+
34
+ - `BitcoinCore` tags each JSON-RPC call with a unique id and rejects a response
35
+ that does not echo it (a stale or mixed-up connection), after surfacing any
36
+ bitcoind error first.
37
+ - More transport failures (`Timeout::Error`, `Net::ProtocolError`) are wrapped as
38
+ `NetworkError` instead of escaping as a raw error.
39
+ - The CLI rejects unexpected extra arguments and a non-positive `--timeout`.
40
+
41
+ ## [0.2.0]
42
+
43
+ ### Added
44
+
45
+ - `Chain::BitcoinCore`, a JSON-RPC oracle that verifies against your own Bitcoin
46
+ Core node (`getblockhash` + `getblockheader`) for a fully trustless check;
47
+ supports basic-auth credentials via argument, URL, or `.cookie` file.
48
+ - `ots` command-line tool (`stamp | upgrade | verify | info | version`), a thin
49
+ wrapper over the library. `verify` binds the proof to the file and can target a
50
+ node (`--node`) and require an m-of-n quorum (`--quorum`).
51
+ - `verify` / `verified?` take a `quorum:` (m-of-n): require the proof to be
52
+ anchored in at least that many *distinct Bitcoin blocks*. Several tree paths
53
+ proving the same anchor collapse to one, so a single anchor can never satisfy
54
+ quorum > 1.
55
+ - `OpenTimestamps.digest_for_kind`, which hashes data with any file-hash op
56
+ (sha1 / ripemd160 / sha256) to bind a proof to a document.
57
+
58
+ ### Changed
59
+
60
+ - Timestamp children now serialize in the reference client's canonical order (ops
61
+ by `[tag, operand]`, Bitcoin attestations by height, pending by uri), so a proof
62
+ we build or merge is byte-identical to `ots` output and any reference proof
63
+ still round-trips byte-for-byte.
64
+
65
+ ### Hardened
66
+
67
+ - Duplicate attestations are dropped on deserialize (the reference keeps a set),
68
+ and a binary op with an empty operand is rejected, closing two ways a proof
69
+ could otherwise pad a quorum from a single real anchor.
70
+ - Chain oracles validate the shape of everything they read (merkle-root hex,
71
+ block hash, JSON-RPC envelope, HTTP status) and raise `NetworkError` rather than
72
+ a raw Ruby error on malformed responses.
73
+
74
+ ### Internal
75
+
76
+ - `Calendar`, `Chain::Explorer`, and `Chain::BitcoinCore` take an injectable
77
+ transport; `stamp_digest` / `upgrade` take a `calendar_factory`, enabling
78
+ offline unit tests of the network paths and merge logic.
79
+
8
80
  ## [0.1.0]
9
81
 
10
82
  Initial release: a pure-Ruby OpenTimestamps client with no runtime dependencies.
@@ -31,5 +103,7 @@ caps; only `DeserializationError` on malformed bytes). Tested with byte-exact
31
103
  round-trips of twelve reference vectors, an offline check against Bitcoin block
32
104
  358391, and Keccak-256 known-answer vectors.
33
105
 
34
- [Unreleased]: https://github.com/sirruf/opentimestamps/compare/v0.1.0...HEAD
106
+ [Unreleased]: https://github.com/sirruf/opentimestamps/compare/v0.3.0...HEAD
107
+ [0.3.0]: https://github.com/sirruf/opentimestamps/compare/v0.2.0...v0.3.0
108
+ [0.2.0]: https://github.com/sirruf/opentimestamps/compare/v0.1.0...v0.2.0
35
109
  [0.1.0]: https://github.com/sirruf/opentimestamps/releases/tag/v0.1.0
data/README.md CHANGED
@@ -75,13 +75,44 @@ byte-for-byte, and checks a proof against Bitcoin block 358391 using a recorded
75
75
  merkle root, offline. The reference `ots` client also reads proofs this gem
76
76
  creates.
77
77
 
78
+ ## Command line
79
+
80
+ The gem installs an `ots` executable, a thin wrapper over the library:
81
+
82
+ ```console
83
+ $ ots stamp report.pdf # writes report.pdf.ots (pending)
84
+ $ ots upgrade report.pdf.ots # an hour later: fold in the Bitcoin path
85
+ $ ots verify report.pdf # checks report.pdf.ots and binds it to the file
86
+ $ ots info report.pdf.ots # dump the proof's structure
87
+ ```
88
+
89
+ `verify` recomputes the file's hash and confirms it matches the proof, so a
90
+ success means *this* document is anchored, not just that the proof is valid. It
91
+ can also require the proof to be anchored in several distinct Bitcoin blocks
92
+ (`--quorum 2`) and check against your own node (see below).
93
+
94
+ ## Verifying against your own node
95
+
96
+ `Chain::Explorer` trusts a public explorer. For a check that trusts nothing
97
+ external, point verification at your own Bitcoin Core:
98
+
99
+ ```ruby
100
+ chain = OpenTimestamps::Chain::BitcoinCore.new(
101
+ "http://127.0.0.1:8332", user: "rpcuser", password: "secret"
102
+ )
103
+ OpenTimestamps.verify(ots, chain: chain, quorum: 2)
104
+ ```
105
+
106
+ `BitcoinCore.from_cookie(path)` reads the node's `.cookie` file instead of a
107
+ password. From the CLI: `ots verify file --node http://127.0.0.1:8332 --cookie ~/.bitcoin/.cookie`.
108
+
78
109
  ## Roadmap
79
110
 
80
111
  - [x] Byte-exact interop with reference `.ots` vectors, both directions.
81
112
  - [x] Multi-calendar submit with merge (a single calendar being down is not fatal).
82
- - [ ] `Chain::BitcoinCore` JSON-RPC adapter (verification against your own node).
83
- - [ ] Calendar quorum on upgrade (m-of-n).
84
- - [ ] Optional CLI (`ots stamp | upgrade | verify | info`).
113
+ - [x] `Chain::BitcoinCore` JSON-RPC adapter (verification against your own node).
114
+ - [x] Quorum (m-of-n distinct Bitcoin blocks), enforced at verify.
115
+ - [x] CLI (`ots stamp | upgrade | verify | info`).
85
116
 
86
117
  ## License
87
118
 
data/exe/ots ADDED
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "opentimestamps"
6
+
7
+ # Command-line front end for the opentimestamps gem: stamp a file against the
8
+ # Bitcoin blockchain, upgrade the pending proof once a calendar anchors it, and
9
+ # verify it. Thin wrapper over the library API.
10
+ module OTSCLI
11
+ module_function
12
+
13
+ def main(argv)
14
+ command = argv.shift
15
+ case command
16
+ when "stamp" then stamp(argv)
17
+ when "upgrade" then upgrade(argv)
18
+ when "verify" then verify(argv)
19
+ when "info" then info(argv)
20
+ when "version", "--version", "-v" then puts "opentimestamps #{OpenTimestamps::VERSION}"
21
+ when nil, "help", "--help", "-h" then warn(usage) || (exit(command.nil? ? 1 : 0))
22
+ else
23
+ warn "ots: unknown command #{command.inspect}\n\n#{usage}"
24
+ exit 1
25
+ end
26
+ rescue OpenTimestamps::Error, OptionParser::ParseError, SystemCallError, IOError, URI::InvalidURIError => e
27
+ warn "ots: #{e.message}"
28
+ exit 1
29
+ rescue Interrupt
30
+ warn "ots: interrupted"
31
+ exit 130
32
+ end
33
+
34
+ def usage
35
+ <<~TXT
36
+ Usage: ots <command> [options]
37
+
38
+ Commands:
39
+ stamp <file> Create <file>.ots (submits the file hash to calendars)
40
+ upgrade <file.ots> Fold in the Bitcoin path once a calendar has anchored it
41
+ verify <file> Check <file>.ots against the chain and bind it to <file>
42
+ info <file.ots> Print the structure of a proof
43
+ version Print the library version
44
+
45
+ Run "ots <command> --help" for command-specific options.
46
+ TXT
47
+ end
48
+
49
+ def stamp(argv)
50
+ opts = { calendars: [], timeout: 20 }
51
+ OptionParser.new do |p|
52
+ p.banner = "Usage: ots stamp <file> [options]"
53
+ p.on("-c", "--calendar URL", "Calendar URL (repeatable; defaults to the public calendars)") { |u| opts[:calendars] << u }
54
+ p.on("-o", "--out PATH", "Output path (default: <file>.ots)") { |o| opts[:out] = o }
55
+ p.on("--timeout SECONDS", Integer, "Per-request timeout (default: 20)") { |t| opts[:timeout] = t }
56
+ end.parse!(argv)
57
+
58
+ file = require_arg(argv, "stamp <file>")
59
+ no_extra(argv)
60
+ check_timeout(opts[:timeout])
61
+ out = opts[:out] || "#{file}.ots"
62
+
63
+ calendars = opts[:calendars].empty? ? OpenTimestamps::DEFAULT_CALENDARS : opts[:calendars]
64
+ # Stream the file through the hash rather than reading it whole into memory.
65
+ digest = OpenTimestamps.digest_file(file, :sha256)
66
+ detached = OpenTimestamps.stamp_digest(digest, calendars: calendars, timeout: opts[:timeout])
67
+ write_new(out, detached.serialize)
68
+ warn "Stamped #{file} -> #{out} (pending; upgrade in an hour or so)"
69
+ end
70
+
71
+ def upgrade(argv)
72
+ opts = { timeout: 20 }
73
+ OptionParser.new do |p|
74
+ p.banner = "Usage: ots upgrade <file.ots> [options]"
75
+ p.on("--timeout SECONDS", Integer, "Per-request timeout (default: 20)") { |t| opts[:timeout] = t }
76
+ end.parse!(argv)
77
+
78
+ path = require_arg(argv, "upgrade <file.ots>")
79
+ no_extra(argv)
80
+ check_timeout(opts[:timeout])
81
+ detached = OpenTimestamps::DetachedTimestampFile.deserialize(File.binread(path))
82
+ if OpenTimestamps.upgrade(detached, timeout: opts[:timeout])
83
+ write_atomic(path, detached.serialize)
84
+ warn "Upgraded #{path} (now carries a Bitcoin attestation)"
85
+ else
86
+ warn "No change: #{path} is already anchored, or no calendar has anchored it yet."
87
+ end
88
+ end
89
+
90
+ def verify(argv)
91
+ opts = { quorum: 1 }
92
+ OptionParser.new do |p|
93
+ p.banner = "Usage: ots verify <file> [options]"
94
+ p.on("-o", "--ots PATH", "Proof path (default: <file>.ots)") { |o| opts[:ots] = o }
95
+ p.on("--quorum N", Integer, "Require N independent Bitcoin anchors (default: 1)") { |n| opts[:quorum] = n }
96
+ p.on("--node URL", "Verify against your Bitcoin Core node instead of a public explorer") { |u| opts[:node] = u }
97
+ p.on("--rpcuser USER", "Bitcoin Core RPC user") { |u| opts[:user] = u }
98
+ p.on("--rpcpassword PASS", "Bitcoin Core RPC password") { |v| opts[:password] = v }
99
+ p.on("--cookie PATH", "Bitcoin Core .cookie file for RPC auth") { |c| opts[:cookie] = c }
100
+ end.parse!(argv)
101
+
102
+ file = require_arg(argv, "verify <file>")
103
+ no_extra(argv)
104
+ ots_path = opts[:ots] || "#{file}.ots"
105
+ detached = OpenTimestamps::DetachedTimestampFile.deserialize(File.binread(ots_path))
106
+
107
+ # Bind the proof to the document first: it is cheap and needs no network, so a
108
+ # mismatch fails fast before any chain lookup.
109
+ unless bound_to_file?(detached, file)
110
+ warn "ots: #{ots_path} is a valid proof but does NOT match #{file}"
111
+ exit 1
112
+ end
113
+
114
+ chain = chain_for(opts)
115
+ results = OpenTimestamps.verify(detached, chain: chain, quorum: opts[:quorum])
116
+ results.each { |r| puts "OK: #{file} anchored in Bitcoin block ##{r.height} at #{r.time.iso8601}" }
117
+ end
118
+
119
+ def info(argv)
120
+ OptionParser.new { |p| p.banner = "Usage: ots info <file.ots>" }.parse!(argv)
121
+ path = require_arg(argv, "info <file.ots>")
122
+ no_extra(argv)
123
+ detached = OpenTimestamps::DetachedTimestampFile.deserialize(File.binread(path))
124
+ puts OpenTimestamps.info(detached)
125
+ end
126
+
127
+ # --- helpers ---
128
+
129
+ def chain_for(opts)
130
+ return OpenTimestamps::Chain::Explorer.new unless opts[:node]
131
+
132
+ if opts[:cookie]
133
+ OpenTimestamps::Chain::BitcoinCore.from_cookie(opts[:cookie], opts[:node])
134
+ else
135
+ OpenTimestamps::Chain::BitcoinCore.new(opts[:node], user: opts[:user], password: opts[:password])
136
+ end
137
+ end
138
+
139
+ # Recompute the file's hash with the proof's file-hash op and compare it to the
140
+ # digest the proof commits to; this is what binds a proof to a document.
141
+ def bound_to_file?(detached, file)
142
+ OpenTimestamps.digest_file(file, detached.file_hash_op.kind) == detached.file_digest
143
+ end
144
+
145
+ def require_arg(argv, shape)
146
+ arg = argv.shift
147
+ return arg if arg
148
+
149
+ warn "ots: missing argument\nUsage: ots #{shape}"
150
+ exit 1
151
+ end
152
+
153
+ # Reject leftover positional arguments rather than silently ignoring them.
154
+ def no_extra(argv)
155
+ return if argv.empty?
156
+
157
+ warn "ots: unexpected argument(s): #{argv.join(' ')}"
158
+ exit 1
159
+ end
160
+
161
+ def check_timeout(seconds)
162
+ return if seconds.positive?
163
+
164
+ warn "ots: --timeout must be a positive integer (seconds)"
165
+ exit 1
166
+ end
167
+
168
+ # Create a new file, refusing to clobber an existing one. O_EXCL makes the
169
+ # check-and-create atomic (no TOCTOU window).
170
+ def write_new(path, bytes)
171
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY) { |f| f.write(bytes) }
172
+ rescue Errno::EEXIST
173
+ warn "ots: #{path} already exists; refusing to overwrite"
174
+ exit 1
175
+ end
176
+
177
+ # Replace an existing file atomically: write a sibling temp file, then rename it
178
+ # over the target, so a crash mid-write cannot destroy the only copy of a proof.
179
+ def write_atomic(path, bytes)
180
+ tmp = "#{path}.tmp.#{Process.pid}"
181
+ File.binwrite(tmp, bytes)
182
+ File.rename(tmp, path)
183
+ ensure
184
+ File.unlink(tmp) if tmp && File.exist?(tmp)
185
+ end
186
+ end
187
+
188
+ OTSCLI.main(ARGV)
@@ -46,6 +46,7 @@ module OpenTimestamps
46
46
 
47
47
  def pending? = true
48
48
  def components = [@uri]
49
+ def sort_key = [PENDING_TAG, @uri.b]
49
50
  def to_s = "PENDING #{@uri}"
50
51
 
51
52
  def serialize(writer)
@@ -64,6 +65,7 @@ module OpenTimestamps
64
65
 
65
66
  def bitcoin? = true
66
67
  def components = [@height]
68
+ def sort_key = [BITCOIN_TAG, @height]
67
69
  def to_s = "BITCOIN block ##{@height}"
68
70
 
69
71
  def serialize(writer)
@@ -83,6 +85,7 @@ module OpenTimestamps
83
85
  end
84
86
 
85
87
  def components = [@tag, @payload]
88
+ def sort_key = [@tag, @payload]
86
89
  def to_s = "UNKNOWN(#{@tag.unpack1('H*')})"
87
90
 
88
91
  def serialize(writer)
@@ -13,8 +13,12 @@ module OpenTimestamps
13
13
 
14
14
  attr_reader :url
15
15
 
16
- def initialize(url)
16
+ # +transport+ is an injection seam for tests and custom clients: a callable
17
+ # taking (request, timeout) and returning [status_string, body_bytes]. It
18
+ # defaults to a capped Net::HTTP round-trip.
19
+ def initialize(url, transport: nil)
17
20
  @url = url.to_s.chomp("/")
21
+ @transport = transport
18
22
  end
19
23
 
20
24
  # Submit a digest; returns a pending Timestamp rooted at that digest.
@@ -44,6 +48,8 @@ module OpenTimestamps
44
48
  def host = URI(@url).host
45
49
 
46
50
  def request(req, timeout)
51
+ return @transport.call(req, timeout) if @transport
52
+
47
53
  uri = URI(@url)
48
54
  req["Accept"] = MIME
49
55
  req["User-Agent"] = "opentimestamps-ruby/#{VERSION}"
@@ -4,6 +4,7 @@ require "net/http"
4
4
  require "uri"
5
5
  require "json"
6
6
  require "time"
7
+ require "securerandom"
7
8
 
8
9
  module OpenTimestamps
9
10
  # A chain oracle resolves a block height to its merkle root (in internal byte
@@ -11,27 +12,51 @@ module OpenTimestamps
11
12
  # depends only on this interface, so a Bitcoin node can be dropped in for a
12
13
  # fully trustless check.
13
14
  module Chain
15
+ MAX_RESPONSE_BYTES = 1 << 20
16
+
17
+ HEX64 = /\A[0-9a-fA-F]{64}\z/
18
+
19
+ # Turn a block's display-order merkle-root hex into internal byte order, the
20
+ # form an OTS commitment is compared against. Rejects anything that is not a
21
+ # 32-byte hex string so junk from an oracle fails loudly, not as a silent
22
+ # mismatch.
23
+ def self.root_display_to_internal(hex)
24
+ raise NetworkError, "chain oracle returned an invalid merkle root" unless hex.is_a?(String) && hex.match?(HEX64)
25
+
26
+ [hex].pack("H*").reverse
27
+ end
28
+
14
29
  # Public block explorer (Esplora API). Convenient, but trusts the explorer.
15
30
  class Explorer
16
- MAX_RESPONSE_BYTES = 1 << 20
31
+ # Preserved for backward compatibility; the cap now lives on the module.
32
+ MAX_RESPONSE_BYTES = Chain::MAX_RESPONSE_BYTES
17
33
 
18
- def initialize(base = "https://blockstream.info/api", timeout: 20)
34
+ # +transport+ is an injection seam for tests and custom clients: a callable
35
+ # taking a URL and returning the response body. It defaults to a capped
36
+ # Net::HTTP GET.
37
+ def initialize(base = "https://blockstream.info/api", timeout: 20, transport: nil)
19
38
  @base = base.chomp("/")
20
39
  @timeout = timeout
40
+ @transport = transport
21
41
  end
22
42
 
23
43
  def block_merkle_root_and_time(height)
24
- hash = get("#{@base}/block-height/#{height}").strip
44
+ hash = get("#{@base}/block-height/#{Integer(height)}").strip
45
+ raise NetworkError, "explorer returned an invalid block hash" unless hash.match?(Chain::HEX64)
46
+
25
47
  blk = JSON.parse(get("#{@base}/block/#{hash}"))
26
- root_internal = [blk.fetch("merkle_root")].pack("H*").reverse # display -> internal
27
- [root_internal, Time.at(blk.fetch("timestamp")).utc]
28
- rescue JSON::ParserError, KeyError => e
48
+ raise NetworkError, "explorer returned unexpected data" unless blk.is_a?(Hash)
49
+
50
+ [Chain.root_display_to_internal(blk.fetch("merkle_root")), Time.at(Integer(blk.fetch("timestamp"))).utc]
51
+ rescue JSON::ParserError, KeyError, TypeError, ArgumentError => e
29
52
  raise NetworkError, "explorer returned unexpected data: #{e.class}"
30
53
  end
31
54
 
32
55
  private
33
56
 
34
57
  def get(url)
58
+ return @transport.call(url) if @transport
59
+
35
60
  uri = URI(url)
36
61
  http = Net::HTTP.new(uri.host, uri.port)
37
62
  http.use_ssl = (uri.scheme == "https")
@@ -40,24 +65,136 @@ module OpenTimestamps
40
65
 
41
66
  http.start do |conn|
42
67
  conn.request(Net::HTTP::Get.new(uri)) do |res|
68
+ # A 404 means the block does not exist (height past the tip, or a bogus
69
+ # proof), which is a verification fact, not an outage.
70
+ raise BlockNotFound, "explorer: block not found (HTTP 404)" if res.code == "404"
43
71
  raise NetworkError, "explorer: HTTP #{res.code}" unless res.code == "200"
44
72
 
45
- return read_capped(res)
73
+ return Chain.read_capped(res) { |n| "explorer: response exceeds #{n} bytes" }
46
74
  end
47
75
  end
48
- rescue SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout,
76
+ rescue SocketError, SystemCallError, Timeout::Error, Net::ProtocolError,
49
77
  OpenSSL::SSL::SSLError, IOError => e
50
78
  raise NetworkError, "explorer: #{e.class}: #{e.message}"
51
79
  end
80
+ end
81
+
82
+ # Your own Bitcoin Core node over JSON-RPC. Trusts nothing external: the
83
+ # merkle root and time come straight from the block header your node holds.
84
+ #
85
+ # Chain::BitcoinCore.new("http://127.0.0.1:8332", user: "rpcuser", password: "secret")
86
+ #
87
+ # Credentials may also be embedded in the URL (http://user:pass@host:port) or
88
+ # read from the node's cookie file (Chain::BitcoinCore.from_cookie(path)).
89
+ class BitcoinCore
90
+ # +transport+ is an injection seam for tests: a callable taking the request
91
+ # body (a JSON string) and returning the response body. It defaults to a
92
+ # capped Net::HTTP POST with basic auth.
93
+ def initialize(url = "http://127.0.0.1:8332", user: nil, password: nil, timeout: 20, transport: nil)
94
+ @uri = URI(url)
95
+ # userinfo is percent-encoded per RFC 3986, not form-encoded, so "+" is a
96
+ # literal plus, not a space.
97
+ @user = user || unescape(@uri.user)
98
+ @password = password || unescape(@uri.password)
99
+ @timeout = timeout
100
+ @transport = transport
101
+ rescue URI::InvalidURIError => e
102
+ raise Error, "invalid node URL: #{e.message}"
103
+ end
104
+
105
+ # Build a client from a node's .cookie file (contents are "__cookie__:hex").
106
+ def self.from_cookie(cookie_path, url = "http://127.0.0.1:8332", **opts)
107
+ user, password = File.read(cookie_path).strip.split(":", 2)
108
+ raise Error, "malformed cookie file #{cookie_path}" unless user && password
109
+
110
+ new(url, user: user, password: password, **opts)
111
+ end
112
+
113
+ def block_merkle_root_and_time(height)
114
+ hash = rpc("getblockhash", [Integer(height)])
115
+ raise NetworkError, "bitcoind returned an invalid block hash" unless hash.is_a?(String)
116
+
117
+ header = rpc("getblockheader", [hash])
118
+ raise NetworkError, "bitcoind returned unexpected data" unless header.is_a?(Hash)
119
+
120
+ [Chain.root_display_to_internal(header.fetch("merkleroot")), Time.at(Integer(header.fetch("time"))).utc]
121
+ rescue KeyError, TypeError, ArgumentError => e
122
+ raise NetworkError, "bitcoind returned unexpected data: #{e.class}"
123
+ end
124
+
125
+ private
126
+
127
+ def unescape(str)
128
+ str && URI::DEFAULT_PARSER.unescape(str)
129
+ end
130
+
131
+ # RPC error codes that mean "this block does not exist" rather than a fault.
132
+ BLOCK_NOT_FOUND_CODES = [-8, -5].freeze # height out of range / block not found
133
+
134
+ def rpc(method, params)
135
+ id = SecureRandom.hex(8)
136
+ body = JSON.generate(jsonrpc: "1.0", id: id, method: method, params: params)
137
+ parsed = JSON.parse(post(body))
138
+ raise NetworkError, "bitcoind returned a non-object JSON-RPC response" unless parsed.is_a?(Hash)
139
+
140
+ # Handle bitcoind's own error before the id check: a request-level error
141
+ # (parse error, bad params) comes back with "id": null, and we want its
142
+ # message, not an id-mismatch complaint.
143
+ if (error = parsed["error"])
144
+ code = error.is_a?(Hash) ? error["code"] : nil
145
+ message = error.is_a?(Hash) ? error["message"] : error
146
+ raise BlockNotFound, "bitcoind: #{message}" if BLOCK_NOT_FOUND_CODES.include?(code)
147
+
148
+ raise NetworkError, "bitcoind error: #{message}"
149
+ end
150
+
151
+ # A per-call random id echoed back mismatching means the response was not
152
+ # for this call (a stale or mixed-up connection).
153
+ raise NetworkError, "bitcoind response id mismatch" if parsed.key?("id") && parsed["id"] != id
154
+
155
+ parsed.fetch("result")
156
+ rescue JSON::ParserError => e
157
+ raise NetworkError, "bitcoind returned non-JSON: #{e.class}"
158
+ end
159
+
160
+ def post(body)
161
+ return @transport.call(body) if @transport
52
162
 
53
- def read_capped(res)
54
- buffer = +"".b
55
- res.read_body do |chunk|
56
- buffer << chunk
57
- raise NetworkError, "explorer: response exceeds #{MAX_RESPONSE_BYTES} bytes" if buffer.bytesize > MAX_RESPONSE_BYTES
163
+ req = Net::HTTP::Post.new(@uri)
164
+ req["Content-Type"] = "application/json"
165
+ req.basic_auth(@user, @password) if @user
166
+ req.body = body
167
+
168
+ http = Net::HTTP.new(@uri.host, @uri.port)
169
+ http.use_ssl = (@uri.scheme == "https")
170
+ http.open_timeout = @timeout
171
+ http.read_timeout = @timeout
172
+
173
+ http.start do |conn|
174
+ conn.request(req) do |res|
175
+ # bitcoind answers 200 on success and 500 with a JSON error body for
176
+ # RPC-level errors (parsed in #rpc). Any other status is a transport or
177
+ # auth failure, not a usable response.
178
+ raise NetworkError, "bitcoind: HTTP #{res.code}" unless %w[200 500].include?(res.code)
179
+
180
+ return Chain.read_capped(res) { |n| "bitcoind: response exceeds #{n} bytes" }
181
+ end
58
182
  end
59
- buffer
183
+ rescue SocketError, SystemCallError, Timeout::Error, Net::ProtocolError,
184
+ OpenSSL::SSL::SSLError, IOError => e
185
+ raise NetworkError, "bitcoind: #{e.class}: #{e.message}"
186
+ end
187
+ end
188
+
189
+ # Stream a response body, capping its size. The block builds the error message
190
+ # so each caller names itself.
191
+ def self.read_capped(res)
192
+ buffer = +"".b
193
+ res.read_body do |chunk|
194
+ buffer << chunk
195
+ raise NetworkError, yield(MAX_RESPONSE_BYTES) if buffer.bytesize > MAX_RESPONSE_BYTES
60
196
  end
197
+ buffer
61
198
  end
62
199
  end
63
200
  end
@@ -14,4 +14,10 @@ module OpenTimestamps
14
14
 
15
15
  # Raised when a calendar or chain oracle cannot be reached or answers badly.
16
16
  class NetworkError < Error; end
17
+
18
+ # Raised by a chain oracle when the block a proof references does not exist (a
19
+ # height past the chain tip, or a bogus attestation). This is a fact about the
20
+ # proof, not an outage, so verification treats it as "not anchored" rather than
21
+ # as an unreachable oracle.
22
+ class BlockNotFound < Error; end
17
23
  end
@@ -18,7 +18,10 @@ module OpenTimestamps
18
18
  def initialize(kind, arg = nil)
19
19
  raise Error, "unknown op #{kind}" unless TAG.key?(kind)
20
20
  raise Error, "#{kind} takes no argument" if arg && UNARY.value?(kind)
21
- raise Error, "#{kind} requires an argument" if arg.nil? && BINARY.value?(kind)
21
+ # A binary op must carry a non-empty operand: the reference requires
22
+ # min_len 1, and an empty operand would be an identity step that could pad a
23
+ # proof with extra tree paths to the same commitment.
24
+ raise Error, "#{kind} requires a non-empty argument" if BINARY.value?(kind) && (arg.nil? || arg.empty?)
22
25
 
23
26
  @kind = kind
24
27
  @arg = arg&.b
@@ -26,6 +29,10 @@ module OpenTimestamps
26
29
 
27
30
  def binary? = BINARY.value?(@kind)
28
31
 
32
+ # Canonical sort key matching the reference client: order by tag, then by the
33
+ # raw operand bytes (empty for a unary op).
34
+ def sort_key = [TAG.fetch(@kind), @arg || "".b]
35
+
29
36
  # Apply the operation to a message, returning the new bytes.
30
37
  def apply(msg)
31
38
  case @kind
@@ -50,7 +57,10 @@ module OpenTimestamps
50
57
 
51
58
  def self.deserialize(reader, tag)
52
59
  if BINARY.key?(tag)
53
- new(BINARY[tag], reader.varbytes)
60
+ arg = reader.varbytes
61
+ raise DeserializationError, "#{BINARY[tag]} op with empty argument" if arg.empty?
62
+
63
+ new(BINARY[tag], arg)
54
64
  elsif UNARY.key?(tag)
55
65
  new(UNARY[tag])
56
66
  else
@@ -34,7 +34,10 @@ module OpenTimestamps
34
34
 
35
35
  def self.read_child(reader, node, tag, depth)
36
36
  if tag == 0x00
37
- node.attestations << Attestation.deserialize(reader)
37
+ att = Attestation.deserialize(reader)
38
+ # The reference client keeps attestations in a set; mirror that so a proof
39
+ # cannot carry the same attestation twice (which would inflate a quorum).
40
+ node.attestations << att unless node.attestations.include?(att)
38
41
  else
39
42
  op = Op.deserialize(reader, tag)
40
43
  child_msg = op.apply(node.msg)
@@ -45,17 +48,22 @@ module OpenTimestamps
45
48
  end
46
49
  private_class_method :read_child
47
50
 
51
+ # Children are written in a canonical order (attestations then ops), each
52
+ # sorted by the same key the reference client uses: ops by [tag, raw operand],
53
+ # attestations by [tag, type-specific field] (Bitcoin by height, Pending by
54
+ # uri). A proof `ots` produced is already in this order, so re-serializing it
55
+ # is byte-exact; a proof we built or merged is normalized to match `ots`.
48
56
  def serialize(writer)
49
57
  total = @attestations.size + @ops.size
50
58
  raise Error, "timestamp node has no children" if total.zero?
51
59
 
52
60
  written = 0
53
- @attestations.each do |att|
61
+ @attestations.sort_by(&:sort_key).each do |att|
54
62
  writer.u8(0xff) if (written += 1) < total
55
63
  writer.u8(0x00)
56
64
  att.serialize(writer)
57
65
  end
58
- @ops.each do |op, child|
66
+ @ops.sort_by { |op, _| op.sort_key }.each do |op, child|
59
67
  writer.u8(0xff) if (written += 1) < total
60
68
  op.serialize(writer)
61
69
  child.serialize(writer)
@@ -98,13 +106,25 @@ module OpenTimestamps
98
106
  end
99
107
 
100
108
  # Checks every Bitcoin attestation against the chain oracle. Returns one
101
- # { height:, time:, verified: } entry per Bitcoin attestation.
109
+ # { height:, time:, verified:, commitment:, error: } entry per Bitcoin
110
+ # attestation. The commitment lets a caller collapse several tree paths that
111
+ # prove the same anchor (same height and root) into one; +error+ is set when
112
+ # the oracle could not be reached for that block, so the caller can tell "not
113
+ # anchored" apart from "could not check".
102
114
  def verify(chain)
103
115
  each_attestation.filter_map do |commitment, att|
104
116
  next unless att.bitcoin?
105
117
 
106
- root, time = chain.block_merkle_root_and_time(att.height)
107
- { height: att.height, time: time, verified: commitment == root }
118
+ begin
119
+ root, time = chain.block_merkle_root_and_time(att.height)
120
+ { height: att.height, time: time, verified: commitment == root, commitment: commitment, error: nil }
121
+ rescue BlockNotFound
122
+ # The block does not exist: this attestation simply does not verify, the
123
+ # same as a root mismatch, so it carries no outage error.
124
+ { height: att.height, time: nil, verified: false, commitment: commitment, error: nil }
125
+ rescue NetworkError => e
126
+ { height: att.height, time: nil, verified: false, commitment: commitment, error: e }
127
+ end
108
128
  end
109
129
  end
110
130
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenTimestamps
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -28,6 +28,10 @@ module OpenTimestamps
28
28
  # One confirmed Bitcoin attestation: the block that anchors +digest+, and when.
29
29
  Verification = Struct.new(:height, :time, :digest, keyword_init: true)
30
30
 
31
+ # Builds a Calendar from a URL. Overridable in stamp_digest / upgrade as an
32
+ # injection seam for tests and custom transports.
33
+ DEFAULT_CALENDAR_FACTORY = ->(url) { Calendar.new(url) }
34
+
31
35
  module_function
32
36
 
33
37
  # Stamp raw data: hash it, then stamp the digest.
@@ -38,13 +42,14 @@ module OpenTimestamps
38
42
  # Stamp an already-computed digest. The content itself never leaves the caller
39
43
  # (privacy / "sealed" mode). The digest is submitted to every calendar and the
40
44
  # replies are merged, so a single calendar being down is not fatal.
41
- def stamp_digest(digest, calendars: DEFAULT_CALENDARS, hash: :sha256, timeout: 20)
45
+ def stamp_digest(digest, calendars: DEFAULT_CALENDARS, hash: :sha256, timeout: 20,
46
+ calendar_factory: DEFAULT_CALENDAR_FACTORY)
42
47
  DetachedTimestampFile.from_hash(digest, hash: hash) # validate digest length before any network
43
48
 
44
49
  merged = nil
45
50
  failures = []
46
51
  Array(calendars).each do |url|
47
- timestamp = Calendar.new(url).submit(digest, timeout: timeout)
52
+ timestamp = calendar_factory.call(url).submit(digest, timeout: timeout)
48
53
  merged ? merged.merge(timestamp) : merged = timestamp
49
54
  rescue NetworkError => e
50
55
  failures << e.message
@@ -57,38 +62,74 @@ module OpenTimestamps
57
62
  # Ask each pending calendar to upgrade to its Bitcoin path, folding the result
58
63
  # in. Returns true if anything changed; persist the file afterwards. A calendar
59
64
  # that is unreachable is skipped, not fatal.
60
- def upgrade(detached, timeout: 20)
61
- return false if detached.timestamp.each_attestation.any? { |_, att| att.bitcoin? }
65
+ def upgrade(detached, timeout: 20, calendar_factory: DEFAULT_CALENDAR_FACTORY)
66
+ before = detached.serialize
62
67
 
63
- changed = false
64
68
  detached.timestamp.each_attestation.select { |_, att| att.pending? }.each do |commitment, att|
69
+ node = detached.timestamp.find(commitment)
70
+ # Skip a calendar whose commitment is already anchored at this leaf, but keep
71
+ # going for the others: with two calendars, one may anchor days before the
72
+ # other, and a later upgrade must still fold the second one in (so quorum > 1
73
+ # can eventually be met).
74
+ next if node&.attestations&.any?(&:bitcoin?)
75
+
65
76
  upgraded = begin
66
- Calendar.new(att.uri).upgrade(commitment, timeout: timeout)
77
+ calendar_factory.call(att.uri).upgrade(commitment, timeout: timeout)
67
78
  rescue NetworkError
68
79
  nil
69
80
  end
70
- next unless upgraded
71
-
72
- detached.timestamp.find(commitment)&.merge(upgraded)
73
- changed = true
81
+ node&.merge(upgraded) if upgraded
74
82
  end
75
- changed
83
+
84
+ detached.serialize != before
76
85
  end
77
86
 
78
- # Verify against the chain, failing closed: raises VerificationError unless at
79
- # least one Bitcoin attestation matches its block. Returns the confirmed
80
- # attestations, each carrying the proven digest to compare with your document.
81
- def verify(detached, chain: Chain::Explorer.new)
87
+ # Verify against the chain, failing closed: raises VerificationError unless the
88
+ # proof is anchored in at least +quorum+ distinct Bitcoin blocks. Returns one
89
+ # confirmed attestation per distinct block, each carrying the proven digest to
90
+ # compare with your document.
91
+ #
92
+ # +quorum+ counts distinct block heights, not raw attestations: several tree
93
+ # paths that prove the same anchor collapse to one, so a single anchor (however
94
+ # a calendar dresses it up) can never satisfy quorum > 1. Requiring quorum > 1
95
+ # therefore means the document is provably in that many separate blocks.
96
+ def verify(detached, chain: Chain::Explorer.new, quorum: 1)
97
+ raise Error, "quorum must be a positive integer" unless quorum.is_a?(Integer) && quorum >= 1
98
+
82
99
  digest = detached.file_digest
83
- confirmed = detached.timestamp.verify(chain).select { |result| result[:verified] }
84
- raise VerificationError, "not anchored in Bitcoin (still pending, or the proof does not match)" if confirmed.empty?
100
+ results = detached.timestamp.verify(chain)
101
+ confirmed = results.select { |result| result[:verified] }
102
+ .uniq { |result| [result[:height], result[:commitment]] }
103
+ confirmed_heights = confirmed.map { |result| result[:height] }.uniq
104
+
105
+ if confirmed_heights.size < quorum
106
+ outage_heights = results.select { |result| result[:error] }.map { |result| result[:height] }.uniq
107
+ # The best case even if every unreachable block eventually confirmed. If that
108
+ # still falls short, the quorum is unreachable for this proof no matter the
109
+ # oracle, so it is a verification failure, not an outage.
110
+ if (confirmed_heights | outage_heights).size < quorum
111
+ raise VerificationError,
112
+ "not anchored in #{quorum} distinct Bitcoin block(s) " \
113
+ "(confirmed #{confirmed_heights.size}; still pending, or the proof does not match)"
114
+ end
115
+
116
+ # A reachable quorum is only blocked by the oracle being down for some blocks:
117
+ # a transient outage, carried with its cause so the real reason is not lost.
118
+ cause = results.filter_map { |result| result[:error] }.first
119
+ raise NetworkError,
120
+ "chain oracle unreachable for #{(outage_heights - confirmed_heights).size} block(s); " \
121
+ "confirmed #{confirmed_heights.size} of #{quorum} required (#{cause&.message})",
122
+ cause: cause
123
+ end
85
124
 
86
125
  confirmed.map { |result| Verification.new(height: result[:height], time: result[:time], digest: digest) }
87
126
  end
88
127
 
89
- # Boolean form of verify that never raises.
90
- def verified?(detached, chain: Chain::Explorer.new)
91
- !verify(detached, chain: chain).empty?
128
+ # Boolean form of verify: returns false instead of raising when a proof does
129
+ # not verify. A misused +quorum+ (Error) or an unreachable chain (NetworkError)
130
+ # still propagates, since those are not verification outcomes.
131
+ def verified?(detached, chain: Chain::Explorer.new, quorum: 1)
132
+ !verify(detached, chain: chain, quorum: quorum).empty?
92
133
  rescue VerificationError
93
134
  false
94
135
  end
@@ -110,4 +151,36 @@ module OpenTimestamps
110
151
  end
111
152
  end
112
153
  private_class_method :digest_for
154
+
155
+ # Hash +data+ with a given file-hash op kind (:sha1, :ripemd160, :sha256), used
156
+ # to bind a proof to a document. Unlike digest_for, this covers every op a
157
+ # `.ots` file can carry as its file hash.
158
+ def digest_for_kind(data, kind)
159
+ raise Error, "#{kind.inspect} is not a valid file-hash op" unless DetachedTimestampFile::HASH_OPS.key?(kind)
160
+
161
+ Op.new(kind).apply(data)
162
+ end
163
+
164
+ # Stream a file through a file-hash op, so a large file is never read whole into
165
+ # memory. Same result as digest_for_kind(File.binread(path), kind).
166
+ def digest_file(path, kind)
167
+ raise Error, "#{kind.inspect} is not a valid file-hash op" unless DetachedTimestampFile::HASH_OPS.key?(kind)
168
+
169
+ digest = case kind
170
+ when :sha256 then Digest::SHA256.new
171
+ when :sha1 then Digest::SHA1.new
172
+ when :ripemd160
173
+ begin
174
+ OpenSSL::Digest.new("RIPEMD160")
175
+ rescue OpenSSL::Digest::DigestError => e
176
+ raise Error, "ripemd160 unavailable (enable the OpenSSL legacy provider): #{e.message}"
177
+ end
178
+ end
179
+ File.open(path, "rb") do |file|
180
+ while (chunk = file.read(64 * 1024))
181
+ digest.update(chunk)
182
+ end
183
+ end
184
+ digest.digest
185
+ end
113
186
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: opentimestamps
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Artem Kolesnikov
8
8
  autorequire:
9
- bindir: bin
9
+ bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-02 00:00:00.000000000 Z
11
+ date: 2026-09-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -45,13 +45,15 @@ description: |
45
45
  Proofs are self-verifying and outlive both this gem and any single server.
46
46
  email:
47
47
  - sirruf@me.com
48
- executables: []
48
+ executables:
49
+ - ots
49
50
  extensions: []
50
51
  extra_rdoc_files: []
51
52
  files:
52
53
  - CHANGELOG.md
53
54
  - LICENSE
54
55
  - README.md
56
+ - exe/ots
55
57
  - lib/opentimestamps.rb
56
58
  - lib/opentimestamps/attestation.rb
57
59
  - lib/opentimestamps/calendar.rb