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 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenTimestamps
4
+ VERSION = "0.1.0"
5
+ 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: []