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 +4 -4
- data/CHANGELOG.md +75 -1
- data/README.md +34 -3
- data/exe/ots +188 -0
- data/lib/opentimestamps/attestation.rb +3 -0
- data/lib/opentimestamps/calendar.rb +7 -1
- data/lib/opentimestamps/chain.rb +151 -14
- data/lib/opentimestamps/error.rb +6 -0
- data/lib/opentimestamps/operation.rb +12 -2
- data/lib/opentimestamps/timestamp.rb +26 -6
- data/lib/opentimestamps/version.rb +1 -1
- data/lib/opentimestamps.rb +93 -20
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz: '
|
|
3
|
+
metadata.gz: 0b838c1eea0b1a967eca1038728e5aaa07aea6fc10ea932258543cda2d31c91a
|
|
4
|
+
data.tar.gz: '009ed17044167540849e8b07dde1f218a5bd4bd2e55dbf593ecfe07126fe1a54'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
- [
|
|
83
|
-
- [
|
|
84
|
-
- [
|
|
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
|
-
|
|
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}"
|
data/lib/opentimestamps/chain.rb
CHANGED
|
@@ -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
|
-
|
|
31
|
+
# Preserved for backward compatibility; the cap now lives on the module.
|
|
32
|
+
MAX_RESPONSE_BYTES = Chain::MAX_RESPONSE_BYTES
|
|
17
33
|
|
|
18
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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,
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
data/lib/opentimestamps/error.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
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
|
data/lib/opentimestamps.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
77
|
+
calendar_factory.call(att.uri).upgrade(commitment, timeout: timeout)
|
|
67
78
|
rescue NetworkError
|
|
68
79
|
nil
|
|
69
80
|
end
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
detached.timestamp.find(commitment)&.merge(upgraded)
|
|
73
|
-
changed = true
|
|
81
|
+
node&.merge(upgraded) if upgraded
|
|
74
82
|
end
|
|
75
|
-
|
|
83
|
+
|
|
84
|
+
detached.serialize != before
|
|
76
85
|
end
|
|
77
86
|
|
|
78
|
-
# Verify against the chain, failing closed: raises VerificationError unless
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Artem Kolesnikov
|
|
8
8
|
autorequire:
|
|
9
|
-
bindir:
|
|
9
|
+
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
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
|