opentimestamps 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 +35 -0
- data/LICENSE +21 -0
- data/README.md +88 -0
- data/lib/opentimestamps/attestation.rb +93 -0
- data/lib/opentimestamps/calendar.rb +74 -0
- data/lib/opentimestamps/chain.rb +63 -0
- data/lib/opentimestamps/detached_file.rb +60 -0
- data/lib/opentimestamps/error.rb +17 -0
- data/lib/opentimestamps/keccak256.rb +68 -0
- data/lib/opentimestamps/operation.rb +68 -0
- data/lib/opentimestamps/serialization.rb +93 -0
- data/lib/opentimestamps/timestamp.rb +111 -0
- data/lib/opentimestamps/version.rb +5 -0
- data/lib/opentimestamps.rb +113 -0
- metadata +94 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6f146094f21ed3a732a3a7cfa6b799366a25a2434953d16dcf3825052f3c967a
|
|
4
|
+
data.tar.gz: '08b95d3c6e8e7b3853e4753d76171e72eb46b9645c07454ebf6ea2b1407510db'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1d063990950ea15459b67adf25fdb8699dec5ff1ce75901cefdeee5c03b34373207b6a7ec988c1764cc19458576254cc0b8a06e796ea9ad98d282776c63d34c5
|
|
7
|
+
data.tar.gz: 41756a3dd2934d2f236b43c5738524cf121a62f692c026144910aa8150525cad8aa2891585e1bb58ba62ea6b5a39f40c617c7d8d617b31dc98d02208b02bfe1e
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/), and this
|
|
4
|
+
project uses [Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.1.0]
|
|
9
|
+
|
|
10
|
+
Initial release: a pure-Ruby OpenTimestamps client with no runtime dependencies.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `OpenTimestamps.stamp` and `.stamp_digest` submit a hash to the calendar
|
|
15
|
+
servers (`stamp_digest` keeps the content itself private) and merge the
|
|
16
|
+
replies, so one calendar being down is not fatal.
|
|
17
|
+
- `OpenTimestamps.upgrade` folds a calendar's Bitcoin path into a pending proof;
|
|
18
|
+
`verify` / `verified?` check it against a block through a `Chain` oracle and
|
|
19
|
+
fail closed.
|
|
20
|
+
- `DetachedTimestampFile` reads and writes the `.ots` file format.
|
|
21
|
+
- `Timestamp`, `Op`, and `Attestation` (Pending, Bitcoin, Unknown) model the wire
|
|
22
|
+
format, with byte-exact serialization and unknown attestation types preserved
|
|
23
|
+
intact.
|
|
24
|
+
- `Keccak256`, a pure-Ruby implementation of Ethereum's Keccak-256, for proofs
|
|
25
|
+
that also anchor to Ethereum.
|
|
26
|
+
- `Chain::Explorer`, a public block-explorer oracle behind a small interface a
|
|
27
|
+
Bitcoin node can replace.
|
|
28
|
+
|
|
29
|
+
The parser is hardened against untrusted input (depth, varuint, and message-size
|
|
30
|
+
caps; only `DeserializationError` on malformed bytes). Tested with byte-exact
|
|
31
|
+
round-trips of twelve reference vectors, an offline check against Bitcoin block
|
|
32
|
+
358391, and Keccak-256 known-answer vectors.
|
|
33
|
+
|
|
34
|
+
[Unreleased]: https://github.com/sirruf/opentimestamps/compare/v0.1.0...HEAD
|
|
35
|
+
[0.1.0]: https://github.com/sirruf/opentimestamps/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Artem Kolesnikov
|
|
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,88 @@
|
|
|
1
|
+
# opentimestamps
|
|
2
|
+
|
|
3
|
+
A pure-Ruby client for [OpenTimestamps](https://opentimestamps.org), with no
|
|
4
|
+
runtime dependencies. It stamps a hash against the Bitcoin blockchain through
|
|
5
|
+
public calendar servers, upgrades the resulting proof to a block attestation,
|
|
6
|
+
and verifies it, using only the standard library (`digest`, `openssl`,
|
|
7
|
+
`net/http`, `json`). The proofs it produces are self-verifying and stay valid
|
|
8
|
+
without this gem or any particular server.
|
|
9
|
+
|
|
10
|
+
The reference OpenTimestamps clients are Python and JavaScript; nothing
|
|
11
|
+
equivalent is published for Ruby on RubyGems. This gem lets a Ruby app anchor
|
|
12
|
+
and verify timestamps in-process, without shelling out to another language.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
gem "opentimestamps"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
require "opentimestamps"
|
|
24
|
+
|
|
25
|
+
# Stamp: hashes the data (SHA-256), submits the digest to the default calendars,
|
|
26
|
+
# and returns a pending proof. Use stamp_digest to submit a precomputed hash and
|
|
27
|
+
# keep the content itself private.
|
|
28
|
+
ots = OpenTimestamps.stamp("hello world\n")
|
|
29
|
+
|
|
30
|
+
# Persist it. The calendar indexes each submission by a per-request commitment,
|
|
31
|
+
# so if you keep only the hash you can never upgrade. Always save the .ots.
|
|
32
|
+
File.binwrite("hello.txt.ots", ots.serialize)
|
|
33
|
+
|
|
34
|
+
# Upgrade, an hour or so later, once a calendar has anchored the commitment in a
|
|
35
|
+
# Bitcoin transaction. Returns true if the file changed.
|
|
36
|
+
ots = OpenTimestamps::DetachedTimestampFile.deserialize(File.binread("hello.txt.ots"))
|
|
37
|
+
OpenTimestamps.upgrade(ots) && File.binwrite("hello.txt.ots", ots.serialize)
|
|
38
|
+
|
|
39
|
+
# Verify against the chain. Raises VerificationError unless a block attestation
|
|
40
|
+
# matches; returns the confirmed attestations, each carrying the proven digest.
|
|
41
|
+
OpenTimestamps.verify(ots)
|
|
42
|
+
# => [#<struct Verification height=358391, time=2015-05-28 ..., digest="\x00..">]
|
|
43
|
+
|
|
44
|
+
OpenTimestamps.verified?(ots) # boolean form; never raises
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Compare `verify`'s `digest` against the hash of your own file to bind the proof
|
|
48
|
+
to your document.
|
|
49
|
+
|
|
50
|
+
## Notes on the design
|
|
51
|
+
|
|
52
|
+
The parser treats its input as untrusted: malformed bytes, oversized length
|
|
53
|
+
prefixes, and pathologically deep or large proofs all raise `DeserializationError`
|
|
54
|
+
rather than a raw Ruby error, and depth and message-size caps bound the work a
|
|
55
|
+
single proof can cause.
|
|
56
|
+
|
|
57
|
+
Verification goes through a `Chain` oracle (`#block_merkle_root_and_time`), so
|
|
58
|
+
the bundled public-explorer adapter can be swapped for your own Bitcoin node when
|
|
59
|
+
you want a check that trusts nothing external.
|
|
60
|
+
|
|
61
|
+
Attestation types the library does not model (Litecoin, Ethereum, or anything
|
|
62
|
+
added later) are carried through untouched, so re-serializing a proof never
|
|
63
|
+
corrupts it. Ethereum's Keccak-256 is implemented in pure Ruby for the proofs
|
|
64
|
+
that need it; Bitcoin proofs never do.
|
|
65
|
+
|
|
66
|
+
The client never broadcasts a Bitcoin transaction. Calendars batch many digests
|
|
67
|
+
into one; the client only submits and upgrades.
|
|
68
|
+
|
|
69
|
+
## Interop
|
|
70
|
+
|
|
71
|
+
`test/vectors/` holds twelve `.ots` files produced by the reference client
|
|
72
|
+
(among them the timestamp of the Bitcoin whitepaper, plus multi-chain, unknown
|
|
73
|
+
notary, and merkle-tree proofs). The suite asserts each one re-serializes
|
|
74
|
+
byte-for-byte, and checks a proof against Bitcoin block 358391 using a recorded
|
|
75
|
+
merkle root, offline. The reference `ots` client also reads proofs this gem
|
|
76
|
+
creates.
|
|
77
|
+
|
|
78
|
+
## Roadmap
|
|
79
|
+
|
|
80
|
+
- [x] Byte-exact interop with reference `.ots` vectors, both directions.
|
|
81
|
+
- [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`).
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenTimestamps
|
|
4
|
+
# A leaf claim about a commitment: a pending calendar promise, a Bitcoin block
|
|
5
|
+
# header, or an unknown type kept verbatim for forward compatibility.
|
|
6
|
+
module Attestation
|
|
7
|
+
PENDING_TAG = "\x83\xDF\xE3\x0D\x2E\xF9\x0C\x8E".b
|
|
8
|
+
BITCOIN_TAG = "\x05\x88\x96\x0D\x73\xD7\x19\x01".b
|
|
9
|
+
|
|
10
|
+
# Layout: an 8-byte tag then a length-prefixed payload. The length prefix
|
|
11
|
+
# means an unknown tag can be preserved whole. For a known tag we require
|
|
12
|
+
# the payload to be fully consumed, so a crafted trailing byte is rejected
|
|
13
|
+
# rather than silently dropped.
|
|
14
|
+
def self.deserialize(reader)
|
|
15
|
+
tag = reader.read(8)
|
|
16
|
+
payload = reader.varbytes
|
|
17
|
+
return Unknown.new(tag, payload) unless [PENDING_TAG, BITCOIN_TAG].include?(tag)
|
|
18
|
+
|
|
19
|
+
inner = Serialization::Reader.new(payload)
|
|
20
|
+
att = tag == PENDING_TAG ? Pending.new(inner.varbytes) : Bitcoin.new(inner.varuint)
|
|
21
|
+
raise DeserializationError, "trailing bytes in attestation payload" unless inner.eof?
|
|
22
|
+
|
|
23
|
+
att
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Value equality across attestation types, keyed on each type's components.
|
|
27
|
+
class Base
|
|
28
|
+
def bitcoin? = false
|
|
29
|
+
def pending? = false
|
|
30
|
+
|
|
31
|
+
def ==(other)
|
|
32
|
+
other.class == self.class && other.components == components
|
|
33
|
+
end
|
|
34
|
+
alias eql? ==
|
|
35
|
+
|
|
36
|
+
def hash = [self.class, *components].hash
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A calendar has accepted the commitment; it is not yet in a block.
|
|
40
|
+
class Pending < Base
|
|
41
|
+
attr_reader :uri
|
|
42
|
+
|
|
43
|
+
def initialize(uri)
|
|
44
|
+
@uri = uri.dup.force_encoding("UTF-8")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def pending? = true
|
|
48
|
+
def components = [@uri]
|
|
49
|
+
def to_s = "PENDING #{@uri}"
|
|
50
|
+
|
|
51
|
+
def serialize(writer)
|
|
52
|
+
inner = Serialization::Writer.new.varbytes(@uri.b)
|
|
53
|
+
writer.write(PENDING_TAG).varbytes(inner.string)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The commitment equals the merkle root of Bitcoin block +height+.
|
|
58
|
+
class Bitcoin < Base
|
|
59
|
+
attr_reader :height
|
|
60
|
+
|
|
61
|
+
def initialize(height)
|
|
62
|
+
@height = height
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def bitcoin? = true
|
|
66
|
+
def components = [@height]
|
|
67
|
+
def to_s = "BITCOIN block ##{@height}"
|
|
68
|
+
|
|
69
|
+
def serialize(writer)
|
|
70
|
+
inner = Serialization::Writer.new.varuint(@height)
|
|
71
|
+
writer.write(BITCOIN_TAG).varbytes(inner.string)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A type this version does not model (Litecoin, Ethereum, or something newer)
|
|
76
|
+
# carried through untouched so a round-trip never corrupts the proof.
|
|
77
|
+
class Unknown < Base
|
|
78
|
+
attr_reader :tag, :payload
|
|
79
|
+
|
|
80
|
+
def initialize(tag, payload)
|
|
81
|
+
@tag = tag.b
|
|
82
|
+
@payload = payload.b
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def components = [@tag, @payload]
|
|
86
|
+
def to_s = "UNKNOWN(#{@tag.unpack1('H*')})"
|
|
87
|
+
|
|
88
|
+
def serialize(writer)
|
|
89
|
+
writer.write(@tag).varbytes(@payload)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module OpenTimestamps
|
|
7
|
+
# HTTP client for an OpenTimestamps calendar server. The client never
|
|
8
|
+
# broadcasts a Bitcoin transaction itself; the calendar aggregates many
|
|
9
|
+
# digests into one transaction. We only submit and later upgrade.
|
|
10
|
+
class Calendar
|
|
11
|
+
MIME = "application/vnd.opentimestamps.v1"
|
|
12
|
+
MAX_RESPONSE_BYTES = 1 << 20 # a calendar reply is a small commitment path
|
|
13
|
+
|
|
14
|
+
attr_reader :url
|
|
15
|
+
|
|
16
|
+
def initialize(url)
|
|
17
|
+
@url = url.to_s.chomp("/")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Submit a digest; returns a pending Timestamp rooted at that digest.
|
|
21
|
+
def submit(digest, timeout: 20)
|
|
22
|
+
req = Net::HTTP::Post.new(URI("#{@url}/digest"))
|
|
23
|
+
req["Content-Type"] = MIME
|
|
24
|
+
req.body = digest.b
|
|
25
|
+
code, body = request(req, timeout)
|
|
26
|
+
raise NetworkError, "calendar #{host}: HTTP #{code}" unless code == "200"
|
|
27
|
+
|
|
28
|
+
Timestamp.deserialize(Serialization::Reader.new(body), digest.b)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Ask the calendar to upgrade a commitment to its Bitcoin path. Returns a
|
|
32
|
+
# Timestamp rooted at +commitment+, or nil if it is not anchored yet (404).
|
|
33
|
+
def upgrade(commitment, timeout: 20)
|
|
34
|
+
req = Net::HTTP::Get.new(URI("#{@url}/timestamp/#{commitment.unpack1('H*')}"))
|
|
35
|
+
code, body = request(req, timeout)
|
|
36
|
+
return nil if code == "404"
|
|
37
|
+
raise NetworkError, "calendar #{host}: HTTP #{code}" unless code == "200"
|
|
38
|
+
|
|
39
|
+
Timestamp.deserialize(Serialization::Reader.new(body), commitment.b)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def host = URI(@url).host
|
|
45
|
+
|
|
46
|
+
def request(req, timeout)
|
|
47
|
+
uri = URI(@url)
|
|
48
|
+
req["Accept"] = MIME
|
|
49
|
+
req["User-Agent"] = "opentimestamps-ruby/#{VERSION}"
|
|
50
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
51
|
+
http.use_ssl = (uri.scheme == "https")
|
|
52
|
+
http.open_timeout = timeout
|
|
53
|
+
http.read_timeout = timeout
|
|
54
|
+
|
|
55
|
+
http.start do |conn|
|
|
56
|
+
conn.request(req) do |res|
|
|
57
|
+
return [res.code, read_capped(res)]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
rescue SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout,
|
|
61
|
+
OpenSSL::SSL::SSLError, IOError => e
|
|
62
|
+
raise NetworkError, "calendar #{host}: #{e.class}: #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def read_capped(res)
|
|
66
|
+
buffer = +"".b
|
|
67
|
+
res.read_body do |chunk|
|
|
68
|
+
buffer << chunk
|
|
69
|
+
raise NetworkError, "calendar #{host}: response exceeds #{MAX_RESPONSE_BYTES} bytes" if buffer.bytesize > MAX_RESPONSE_BYTES
|
|
70
|
+
end
|
|
71
|
+
buffer
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module OpenTimestamps
|
|
9
|
+
# A chain oracle resolves a block height to its merkle root (in internal byte
|
|
10
|
+
# order, matching what OTS operations produce) and its time. Verification
|
|
11
|
+
# depends only on this interface, so a Bitcoin node can be dropped in for a
|
|
12
|
+
# fully trustless check.
|
|
13
|
+
module Chain
|
|
14
|
+
# Public block explorer (Esplora API). Convenient, but trusts the explorer.
|
|
15
|
+
class Explorer
|
|
16
|
+
MAX_RESPONSE_BYTES = 1 << 20
|
|
17
|
+
|
|
18
|
+
def initialize(base = "https://blockstream.info/api", timeout: 20)
|
|
19
|
+
@base = base.chomp("/")
|
|
20
|
+
@timeout = timeout
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def block_merkle_root_and_time(height)
|
|
24
|
+
hash = get("#{@base}/block-height/#{height}").strip
|
|
25
|
+
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
|
|
29
|
+
raise NetworkError, "explorer returned unexpected data: #{e.class}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def get(url)
|
|
35
|
+
uri = URI(url)
|
|
36
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
37
|
+
http.use_ssl = (uri.scheme == "https")
|
|
38
|
+
http.open_timeout = @timeout
|
|
39
|
+
http.read_timeout = @timeout
|
|
40
|
+
|
|
41
|
+
http.start do |conn|
|
|
42
|
+
conn.request(Net::HTTP::Get.new(uri)) do |res|
|
|
43
|
+
raise NetworkError, "explorer: HTTP #{res.code}" unless res.code == "200"
|
|
44
|
+
|
|
45
|
+
return read_capped(res)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
rescue SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout,
|
|
49
|
+
OpenSSL::SSL::SSLError, IOError => e
|
|
50
|
+
raise NetworkError, "explorer: #{e.class}: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
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
|
|
58
|
+
end
|
|
59
|
+
buffer
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenTimestamps
|
|
4
|
+
# A `.ots` detached timestamp file: a magic header, a version, the operation
|
|
5
|
+
# used to hash the original file, and the timestamp of that file hash.
|
|
6
|
+
class DetachedTimestampFile
|
|
7
|
+
MAJOR_VERSION = 1
|
|
8
|
+
# file-hash op kind => digest length in bytes
|
|
9
|
+
HASH_OPS = { sha1: 20, ripemd160: 20, sha256: 32 }.freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :file_hash_op, :timestamp
|
|
12
|
+
|
|
13
|
+
def initialize(file_hash_op, timestamp)
|
|
14
|
+
len = HASH_OPS[file_hash_op.kind]
|
|
15
|
+
raise Error, "#{file_hash_op.kind} is not a valid file-hash op" unless len
|
|
16
|
+
unless timestamp.msg.bytesize == len
|
|
17
|
+
raise Error, "digest must be #{len} bytes for #{file_hash_op.kind}, got #{timestamp.msg.bytesize}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
@file_hash_op = file_hash_op
|
|
21
|
+
@timestamp = timestamp
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Build a fresh, unstamped file from an already-computed digest.
|
|
25
|
+
def self.from_hash(digest, hash: :sha256)
|
|
26
|
+
new(Op.new(hash), Timestamp.new(digest))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.deserialize(bytes)
|
|
30
|
+
reader = Serialization::Reader.new(bytes)
|
|
31
|
+
magic = reader.read(Serialization::MAGIC.bytesize)
|
|
32
|
+
raise DeserializationError, "not an .ots file (bad magic)" unless magic == Serialization::MAGIC
|
|
33
|
+
|
|
34
|
+
major = reader.varuint
|
|
35
|
+
raise DeserializationError, "unsupported major version #{major}" unless major == MAJOR_VERSION
|
|
36
|
+
|
|
37
|
+
tag = reader.u8
|
|
38
|
+
kind = Op::UNARY[tag]
|
|
39
|
+
len = HASH_OPS[kind]
|
|
40
|
+
raise DeserializationError, format("unsupported file-hash op 0x%02x", tag) unless len
|
|
41
|
+
|
|
42
|
+
digest = reader.read(len)
|
|
43
|
+
file = new(Op.new(kind), Timestamp.deserialize(reader, digest))
|
|
44
|
+
raise DeserializationError, "trailing data after timestamp" unless reader.eof?
|
|
45
|
+
|
|
46
|
+
file
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def serialize
|
|
50
|
+
w = Serialization::Writer.new
|
|
51
|
+
w.write(Serialization::MAGIC).varuint(MAJOR_VERSION)
|
|
52
|
+
w.u8(Op::TAG.fetch(@file_hash_op.kind))
|
|
53
|
+
w.write(@timestamp.msg)
|
|
54
|
+
@timestamp.serialize(w)
|
|
55
|
+
w.string
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def file_digest = @timestamp.msg
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenTimestamps
|
|
4
|
+
# Base class for every error this library raises.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a byte stream does not parse as a valid OTS structure. All
|
|
8
|
+
# parsing of untrusted input funnels through this, never a raw Ruby error.
|
|
9
|
+
class DeserializationError < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised when a proof does not verify against the chain (or has nothing to
|
|
12
|
+
# verify yet).
|
|
13
|
+
class VerificationError < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised when a calendar or chain oracle cannot be reached or answers badly.
|
|
16
|
+
class NetworkError < Error; end
|
|
17
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenTimestamps
|
|
4
|
+
# Ethereum's original Keccak-256 (0x01 padding), which OpenSSL does not ship
|
|
5
|
+
# and which differs from NIST SHA3-256 (0x06 padding). Reached only by OTS
|
|
6
|
+
# proofs that also anchor to Ethereum; Bitcoin proofs never use it.
|
|
7
|
+
module Keccak256
|
|
8
|
+
MASK = (1 << 64) - 1
|
|
9
|
+
RATE = 136 # bytes (1088-bit rate, 512-bit capacity -> 256-bit output)
|
|
10
|
+
|
|
11
|
+
RNDC = [0x0000000000000001, 0x0000000000008082, 0x800000000000808a, 0x8000000080008000,
|
|
12
|
+
0x000000000000808b, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009,
|
|
13
|
+
0x000000000000008a, 0x0000000000000088, 0x0000000080008009, 0x000000008000000a,
|
|
14
|
+
0x000000008000808b, 0x800000000000008b, 0x8000000000008089, 0x8000000000008003,
|
|
15
|
+
0x8000000000008002, 0x8000000000000080, 0x000000000000800a, 0x800000008000000a,
|
|
16
|
+
0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008].freeze
|
|
17
|
+
ROTC = [1, 3, 6, 10, 15, 21, 28, 36, 45, 55, 2, 14, 27, 41, 56, 8, 25, 43, 62, 18, 39, 61, 20, 44].freeze
|
|
18
|
+
PILN = [10, 7, 11, 17, 18, 3, 5, 16, 8, 21, 24, 4, 15, 23, 19, 13, 12, 2, 20, 14, 22, 9, 6, 1].freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def digest(message)
|
|
23
|
+
m = message.b.dup
|
|
24
|
+
pad = RATE - (m.bytesize % RATE)
|
|
25
|
+
tail = "\x00".b * pad
|
|
26
|
+
tail.setbyte(0, 0x01)
|
|
27
|
+
tail.setbyte(pad - 1, tail.getbyte(pad - 1) | 0x80)
|
|
28
|
+
m << tail
|
|
29
|
+
|
|
30
|
+
state = Array.new(25, 0)
|
|
31
|
+
m.bytes.each_slice(RATE) do |block|
|
|
32
|
+
(RATE / 8).times { |i| state[i] ^= block[i * 8, 8].pack("C*").unpack1("Q<") }
|
|
33
|
+
keccak_f(state)
|
|
34
|
+
end
|
|
35
|
+
(0..3).map { |i| [state[i]].pack("Q<") }.join.b
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def hexdigest(message) = digest(message).unpack1("H*")
|
|
39
|
+
|
|
40
|
+
def rotl(x, n) = ((x << n) | (x >> (64 - n))) & MASK
|
|
41
|
+
|
|
42
|
+
def keccak_f(st)
|
|
43
|
+
bc = Array.new(5, 0)
|
|
44
|
+
24.times do |round|
|
|
45
|
+
5.times { |i| bc[i] = st[i] ^ st[i + 5] ^ st[i + 10] ^ st[i + 15] ^ st[i + 20] }
|
|
46
|
+
5.times do |i|
|
|
47
|
+
t = bc[(i + 4) % 5] ^ rotl(bc[(i + 1) % 5], 1)
|
|
48
|
+
(0...25).step(5) { |j| st[j + i] ^= t }
|
|
49
|
+
end
|
|
50
|
+
t = st[1]
|
|
51
|
+
24.times do |i|
|
|
52
|
+
j = PILN[i]
|
|
53
|
+
tmp = st[j]
|
|
54
|
+
st[j] = rotl(t, ROTC[i])
|
|
55
|
+
t = tmp
|
|
56
|
+
end
|
|
57
|
+
(0...25).step(5) do |j|
|
|
58
|
+
5.times { |i| bc[i] = st[j + i] }
|
|
59
|
+
5.times { |i| st[j + i] ^= (~bc[(i + 1) % 5] & MASK) & bc[(i + 2) % 5] }
|
|
60
|
+
end
|
|
61
|
+
st[0] ^= RNDC[round]
|
|
62
|
+
st.map! { |v| v & MASK }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private_class_method :rotl, :keccak_f
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require_relative "keccak256"
|
|
6
|
+
|
|
7
|
+
module OpenTimestamps
|
|
8
|
+
# A commitment operation: either a binary op that folds an argument into the
|
|
9
|
+
# message (append / prepend) or a unary cryptographic digest (sha1 / ripemd160
|
|
10
|
+
# / sha256). Operations are the edges of a timestamp tree.
|
|
11
|
+
class Op
|
|
12
|
+
UNARY = { 0x02 => :sha1, 0x03 => :ripemd160, 0x08 => :sha256, 0x67 => :keccak256 }.freeze
|
|
13
|
+
BINARY = { 0xf0 => :append, 0xf1 => :prepend }.freeze
|
|
14
|
+
TAG = UNARY.merge(BINARY).invert.freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :kind, :arg
|
|
17
|
+
|
|
18
|
+
def initialize(kind, arg = nil)
|
|
19
|
+
raise Error, "unknown op #{kind}" unless TAG.key?(kind)
|
|
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)
|
|
22
|
+
|
|
23
|
+
@kind = kind
|
|
24
|
+
@arg = arg&.b
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def binary? = BINARY.value?(@kind)
|
|
28
|
+
|
|
29
|
+
# Apply the operation to a message, returning the new bytes.
|
|
30
|
+
def apply(msg)
|
|
31
|
+
case @kind
|
|
32
|
+
when :append then msg + @arg
|
|
33
|
+
when :prepend then @arg + msg
|
|
34
|
+
when :sha256 then Digest::SHA256.digest(msg)
|
|
35
|
+
when :sha1 then Digest::SHA1.digest(msg)
|
|
36
|
+
when :keccak256 then Keccak256.digest(msg)
|
|
37
|
+
when :ripemd160
|
|
38
|
+
# OpenSSL 3 hides RIPEMD160 behind the legacy provider; surface a clear error.
|
|
39
|
+
OpenSSL::Digest.digest("RIPEMD160", msg)
|
|
40
|
+
end
|
|
41
|
+
rescue OpenSSL::Digest::DigestError => e
|
|
42
|
+
raise Error, "ripemd160 unavailable (enable the OpenSSL legacy provider): #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def serialize(writer)
|
|
46
|
+
writer.u8(TAG.fetch(@kind))
|
|
47
|
+
writer.varbytes(@arg) if binary?
|
|
48
|
+
writer
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.deserialize(reader, tag)
|
|
52
|
+
if BINARY.key?(tag)
|
|
53
|
+
new(BINARY[tag], reader.varbytes)
|
|
54
|
+
elsif UNARY.key?(tag)
|
|
55
|
+
new(UNARY[tag])
|
|
56
|
+
else
|
|
57
|
+
raise DeserializationError, format("unknown operation tag 0x%02x", tag)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Value equality so Ops work as Hash keys in a timestamp tree.
|
|
62
|
+
def ==(other) = other.is_a?(Op) && other.kind == @kind && other.arg == @arg
|
|
63
|
+
alias eql? ==
|
|
64
|
+
def hash = [@kind, @arg].hash
|
|
65
|
+
|
|
66
|
+
def to_s = binary? ? "#{@kind}(#{@arg.unpack1('H*')})" : @kind.to_s
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenTimestamps
|
|
4
|
+
# Wire primitives shared by every OTS structure: the base-128 varuint,
|
|
5
|
+
# length-prefixed varbytes, and the detached-file magic header. The reader
|
|
6
|
+
# treats its input as untrusted and only ever raises DeserializationError.
|
|
7
|
+
module Serialization
|
|
8
|
+
MAGIC = "\x00OpenTimestamps\x00\x00Proof\x00\xBF\x89\xE2\xE8\x84\xE8\x92\x94".b
|
|
9
|
+
|
|
10
|
+
# Upper bound on any decoded varuint. Real lengths (bounded by a file) and
|
|
11
|
+
# block heights fit comfortably; the cap stops a crafted length prefix from
|
|
12
|
+
# producing a bignum that would blow up byteslice.
|
|
13
|
+
MAX_VARUINT = (1 << 32) - 1
|
|
14
|
+
|
|
15
|
+
class Reader
|
|
16
|
+
def initialize(bytes)
|
|
17
|
+
@b = bytes.b
|
|
18
|
+
@i = 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def eof? = @i >= @b.bytesize
|
|
22
|
+
|
|
23
|
+
def rest = @b.byteslice(@i..) || "".b
|
|
24
|
+
|
|
25
|
+
def u8
|
|
26
|
+
byte = @b.getbyte(@i) or raise DeserializationError, "unexpected end of input"
|
|
27
|
+
@i += 1
|
|
28
|
+
byte
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def read(n)
|
|
32
|
+
raise DeserializationError, "want #{n} bytes, #{@b.bytesize - @i} left" if n > @b.bytesize - @i
|
|
33
|
+
|
|
34
|
+
s = @b.byteslice(@i, n)
|
|
35
|
+
@i += n
|
|
36
|
+
s
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def varuint
|
|
40
|
+
result = 0
|
|
41
|
+
shift = 0
|
|
42
|
+
loop do
|
|
43
|
+
byte = u8
|
|
44
|
+
result |= (byte & 0x7f) << shift
|
|
45
|
+
raise DeserializationError, "varuint out of range" if result > MAX_VARUINT
|
|
46
|
+
|
|
47
|
+
break if (byte & 0x80).zero?
|
|
48
|
+
|
|
49
|
+
shift += 7
|
|
50
|
+
end
|
|
51
|
+
result
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def varbytes = read(varuint)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class Writer
|
|
58
|
+
def initialize
|
|
59
|
+
@string = +"".b
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
attr_reader :string
|
|
63
|
+
|
|
64
|
+
def u8(int)
|
|
65
|
+
@string << int
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def write(bytes)
|
|
70
|
+
@string << bytes.b
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def varuint(n)
|
|
75
|
+
raise Error, "varuint must be non-negative" if n.negative?
|
|
76
|
+
|
|
77
|
+
loop do
|
|
78
|
+
byte = n & 0x7f
|
|
79
|
+
n >>= 7
|
|
80
|
+
byte |= 0x80 if n.positive?
|
|
81
|
+
@string << byte
|
|
82
|
+
break if n.zero?
|
|
83
|
+
end
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def varbytes(bytes)
|
|
88
|
+
varuint(bytes.bytesize)
|
|
89
|
+
write(bytes)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenTimestamps
|
|
4
|
+
# A node in the timestamp tree: a message, the attestations that commit to it,
|
|
5
|
+
# and the operations leading to child nodes. Serialization follows the OTS
|
|
6
|
+
# wire format's 0x00 (attestation) / 0xff (fork) markers.
|
|
7
|
+
class Timestamp
|
|
8
|
+
# Caps that keep an untrusted proof from exhausting the process: a depth
|
|
9
|
+
# limit (real proofs are shallow) and a per-node message-size limit that
|
|
10
|
+
# bounds append/prepend growth.
|
|
11
|
+
MAX_DEPTH = 1_000
|
|
12
|
+
MAX_MSG_BYTES = 1 << 20
|
|
13
|
+
|
|
14
|
+
attr_reader :msg, :attestations, :ops # ops: Hash[Op => Timestamp]
|
|
15
|
+
|
|
16
|
+
def initialize(msg)
|
|
17
|
+
@msg = msg.b
|
|
18
|
+
@attestations = []
|
|
19
|
+
@ops = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.deserialize(reader, initial_msg, depth = 0)
|
|
23
|
+
raise DeserializationError, "timestamp nested too deeply" if depth > MAX_DEPTH
|
|
24
|
+
|
|
25
|
+
node = new(initial_msg)
|
|
26
|
+
tag = reader.u8
|
|
27
|
+
while tag == 0xff # fork: another child follows at this node
|
|
28
|
+
read_child(reader, node, reader.u8, depth)
|
|
29
|
+
tag = reader.u8
|
|
30
|
+
end
|
|
31
|
+
read_child(reader, node, tag, depth) # the last (or only) child
|
|
32
|
+
node
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.read_child(reader, node, tag, depth)
|
|
36
|
+
if tag == 0x00
|
|
37
|
+
node.attestations << Attestation.deserialize(reader)
|
|
38
|
+
else
|
|
39
|
+
op = Op.deserialize(reader, tag)
|
|
40
|
+
child_msg = op.apply(node.msg)
|
|
41
|
+
raise DeserializationError, "commitment message too large" if child_msg.bytesize > MAX_MSG_BYTES
|
|
42
|
+
|
|
43
|
+
node.ops[op] = deserialize(reader, child_msg, depth + 1)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
private_class_method :read_child
|
|
47
|
+
|
|
48
|
+
def serialize(writer)
|
|
49
|
+
total = @attestations.size + @ops.size
|
|
50
|
+
raise Error, "timestamp node has no children" if total.zero?
|
|
51
|
+
|
|
52
|
+
written = 0
|
|
53
|
+
@attestations.each do |att|
|
|
54
|
+
writer.u8(0xff) if (written += 1) < total
|
|
55
|
+
writer.u8(0x00)
|
|
56
|
+
att.serialize(writer)
|
|
57
|
+
end
|
|
58
|
+
@ops.each do |op, child|
|
|
59
|
+
writer.u8(0xff) if (written += 1) < total
|
|
60
|
+
op.serialize(writer)
|
|
61
|
+
child.serialize(writer)
|
|
62
|
+
end
|
|
63
|
+
writer
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Yields [commitment_msg, attestation] for every attestation in the tree.
|
|
67
|
+
def each_attestation(&block)
|
|
68
|
+
return enum_for(:each_attestation) unless block
|
|
69
|
+
|
|
70
|
+
@attestations.each { |att| block.call(@msg, att) }
|
|
71
|
+
@ops.each_value { |child| child.each_attestation(&block) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# First node whose message equals +target+, or nil.
|
|
75
|
+
def find(target)
|
|
76
|
+
return self if @msg == target
|
|
77
|
+
|
|
78
|
+
@ops.each_value do |child|
|
|
79
|
+
found = child.find(target)
|
|
80
|
+
return found if found
|
|
81
|
+
end
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Fold another timestamp rooted at the same message into this one (upgrade).
|
|
86
|
+
def merge(other)
|
|
87
|
+
raise Error, "cannot merge: message mismatch" unless other.msg == @msg
|
|
88
|
+
|
|
89
|
+
other.attestations.each { |a| @attestations << a unless @attestations.include?(a) }
|
|
90
|
+
other.ops.each do |op, child|
|
|
91
|
+
if @ops.key?(op)
|
|
92
|
+
@ops[op].merge(child)
|
|
93
|
+
else
|
|
94
|
+
@ops[op] = child
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
self
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Checks every Bitcoin attestation against the chain oracle. Returns one
|
|
101
|
+
# { height:, time:, verified: } entry per Bitcoin attestation.
|
|
102
|
+
def verify(chain)
|
|
103
|
+
each_attestation.filter_map do |commitment, att|
|
|
104
|
+
next unless att.bitcoin?
|
|
105
|
+
|
|
106
|
+
root, time = chain.block_merkle_root_and_time(att.height)
|
|
107
|
+
{ height: att.height, time: time, verified: commitment == root }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
require_relative "opentimestamps/version"
|
|
6
|
+
require_relative "opentimestamps/error"
|
|
7
|
+
require_relative "opentimestamps/serialization"
|
|
8
|
+
require_relative "opentimestamps/operation"
|
|
9
|
+
require_relative "opentimestamps/attestation"
|
|
10
|
+
require_relative "opentimestamps/timestamp"
|
|
11
|
+
require_relative "opentimestamps/detached_file"
|
|
12
|
+
require_relative "opentimestamps/calendar"
|
|
13
|
+
require_relative "opentimestamps/chain"
|
|
14
|
+
|
|
15
|
+
# Client for the OpenTimestamps protocol, in pure Ruby with no runtime
|
|
16
|
+
# dependencies.
|
|
17
|
+
#
|
|
18
|
+
# ots = OpenTimestamps.stamp("hello world\n") # DetachedTimestampFile (pending)
|
|
19
|
+
# File.binwrite("hello.txt.ots", ots.serialize) # persist, or you cannot upgrade
|
|
20
|
+
# OpenTimestamps.upgrade(ots) # hours later: fold in the Bitcoin path
|
|
21
|
+
# OpenTimestamps.verify(ots) # => [Verification(height:, time:, digest:)]
|
|
22
|
+
module OpenTimestamps
|
|
23
|
+
DEFAULT_CALENDARS = %w[
|
|
24
|
+
https://alice.btc.calendar.opentimestamps.org
|
|
25
|
+
https://bob.btc.calendar.opentimestamps.org
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# One confirmed Bitcoin attestation: the block that anchors +digest+, and when.
|
|
29
|
+
Verification = Struct.new(:height, :time, :digest, keyword_init: true)
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# Stamp raw data: hash it, then stamp the digest.
|
|
34
|
+
def stamp(data, calendars: DEFAULT_CALENDARS, hash: :sha256, timeout: 20)
|
|
35
|
+
stamp_digest(digest_for(data, hash), calendars: calendars, hash: hash, timeout: timeout)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Stamp an already-computed digest. The content itself never leaves the caller
|
|
39
|
+
# (privacy / "sealed" mode). The digest is submitted to every calendar and the
|
|
40
|
+
# replies are merged, so a single calendar being down is not fatal.
|
|
41
|
+
def stamp_digest(digest, calendars: DEFAULT_CALENDARS, hash: :sha256, timeout: 20)
|
|
42
|
+
DetachedTimestampFile.from_hash(digest, hash: hash) # validate digest length before any network
|
|
43
|
+
|
|
44
|
+
merged = nil
|
|
45
|
+
failures = []
|
|
46
|
+
Array(calendars).each do |url|
|
|
47
|
+
timestamp = Calendar.new(url).submit(digest, timeout: timeout)
|
|
48
|
+
merged ? merged.merge(timestamp) : merged = timestamp
|
|
49
|
+
rescue NetworkError => e
|
|
50
|
+
failures << e.message
|
|
51
|
+
end
|
|
52
|
+
raise NetworkError, "every calendar failed: #{failures.join('; ')}" if merged.nil?
|
|
53
|
+
|
|
54
|
+
DetachedTimestampFile.new(Op.new(hash), merged)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Ask each pending calendar to upgrade to its Bitcoin path, folding the result
|
|
58
|
+
# in. Returns true if anything changed; persist the file afterwards. A calendar
|
|
59
|
+
# 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? }
|
|
62
|
+
|
|
63
|
+
changed = false
|
|
64
|
+
detached.timestamp.each_attestation.select { |_, att| att.pending? }.each do |commitment, att|
|
|
65
|
+
upgraded = begin
|
|
66
|
+
Calendar.new(att.uri).upgrade(commitment, timeout: timeout)
|
|
67
|
+
rescue NetworkError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
next unless upgraded
|
|
71
|
+
|
|
72
|
+
detached.timestamp.find(commitment)&.merge(upgraded)
|
|
73
|
+
changed = true
|
|
74
|
+
end
|
|
75
|
+
changed
|
|
76
|
+
end
|
|
77
|
+
|
|
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)
|
|
82
|
+
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?
|
|
85
|
+
|
|
86
|
+
confirmed.map { |result| Verification.new(height: result[:height], time: result[:time], digest: digest) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Boolean form of verify that never raises.
|
|
90
|
+
def verified?(detached, chain: Chain::Explorer.new)
|
|
91
|
+
!verify(detached, chain: chain).empty?
|
|
92
|
+
rescue VerificationError
|
|
93
|
+
false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Human-readable dump of a proof's structure.
|
|
97
|
+
def info(detached)
|
|
98
|
+
lines = ["file digest (#{detached.file_hash_op.kind}): #{detached.file_digest.unpack1('H*')}"]
|
|
99
|
+
detached.timestamp.each_attestation do |commitment, att|
|
|
100
|
+
lines << " #{att} @ #{commitment.unpack1('H*')[0, 32]}..."
|
|
101
|
+
end
|
|
102
|
+
lines.join("\n")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def digest_for(data, hash)
|
|
106
|
+
case hash
|
|
107
|
+
when :sha256 then Digest::SHA256.digest(data)
|
|
108
|
+
when :sha1 then Digest::SHA1.digest(data)
|
|
109
|
+
else raise Error, "cannot hash raw data with #{hash.inspect}; pass a precomputed digest to stamp_digest"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
private_class_method :digest_for
|
|
113
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: opentimestamps
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Artem Kolesnikov
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-02 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.0'
|
|
41
|
+
description: |
|
|
42
|
+
A dependency-free Ruby implementation of the OpenTimestamps protocol: stamp a
|
|
43
|
+
hash against the Bitcoin blockchain via public calendar servers, upgrade the
|
|
44
|
+
proof to a block attestation, and verify it - using only the standard library.
|
|
45
|
+
Proofs are self-verifying and outlive both this gem and any single server.
|
|
46
|
+
email:
|
|
47
|
+
- sirruf@me.com
|
|
48
|
+
executables: []
|
|
49
|
+
extensions: []
|
|
50
|
+
extra_rdoc_files: []
|
|
51
|
+
files:
|
|
52
|
+
- CHANGELOG.md
|
|
53
|
+
- LICENSE
|
|
54
|
+
- README.md
|
|
55
|
+
- lib/opentimestamps.rb
|
|
56
|
+
- lib/opentimestamps/attestation.rb
|
|
57
|
+
- lib/opentimestamps/calendar.rb
|
|
58
|
+
- lib/opentimestamps/chain.rb
|
|
59
|
+
- lib/opentimestamps/detached_file.rb
|
|
60
|
+
- lib/opentimestamps/error.rb
|
|
61
|
+
- lib/opentimestamps/keccak256.rb
|
|
62
|
+
- lib/opentimestamps/operation.rb
|
|
63
|
+
- lib/opentimestamps/serialization.rb
|
|
64
|
+
- lib/opentimestamps/timestamp.rb
|
|
65
|
+
- lib/opentimestamps/version.rb
|
|
66
|
+
homepage: https://github.com/sirruf/opentimestamps
|
|
67
|
+
licenses:
|
|
68
|
+
- MIT
|
|
69
|
+
metadata:
|
|
70
|
+
homepage_uri: https://github.com/sirruf/opentimestamps
|
|
71
|
+
source_code_uri: https://github.com/sirruf/opentimestamps
|
|
72
|
+
changelog_uri: https://github.com/sirruf/opentimestamps/blob/main/CHANGELOG.md
|
|
73
|
+
bug_tracker_uri: https://github.com/sirruf/opentimestamps/issues
|
|
74
|
+
rubygems_mfa_required: 'true'
|
|
75
|
+
post_install_message:
|
|
76
|
+
rdoc_options: []
|
|
77
|
+
require_paths:
|
|
78
|
+
- lib
|
|
79
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '3.0'
|
|
84
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0'
|
|
89
|
+
requirements: []
|
|
90
|
+
rubygems_version: 3.4.10
|
|
91
|
+
signing_key:
|
|
92
|
+
specification_version: 4
|
|
93
|
+
summary: 'Pure-Ruby OpenTimestamps client: Bitcoin-anchored timestamps, zero dependencies.'
|
|
94
|
+
test_files: []
|