From 0b433cd95a2ea666c75ec6f516cf07b142fa31ad Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Mon, 18 Sep 2023 16:29:38 -0300 Subject: [PATCH] copy the other repo and make packages install. --- LICENSE | 202 ++++++++++++++++++++++++++++++++++ README.md | 23 ++++ attestations.go | 183 ++++++++++++++++++++++++++++++ client/bitcoin.go | 99 +++++++++++++++++ client/bitcoin_test.go | 83 ++++++++++++++ commands.go | 26 +++++ detached_timestamp.go | 100 +++++++++++++++++ detached_timestamp_test.go | 110 +++++++++++++++++++ go.mod | 25 +++++ go.sum | 113 +++++++++++++++++++ operations.go | 220 +++++++++++++++++++++++++++++++++++++ operations_test.go | 78 +++++++++++++ remote_calendar.go | 146 ++++++++++++++++++++++++ remote_calendar_test.go | 71 ++++++++++++ serialize.go | 206 ++++++++++++++++++++++++++++++++++ serialize_test.go | 153 ++++++++++++++++++++++++++ timestamp.go | 193 ++++++++++++++++++++++++++++++++ util.go | 11 ++ 18 files changed, 2042 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 attestations.go create mode 100644 client/bitcoin.go create mode 100644 client/bitcoin_test.go create mode 100644 commands.go create mode 100644 detached_timestamp.go create mode 100644 detached_timestamp_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 operations.go create mode 100644 operations_test.go create mode 100644 remote_calendar.go create mode 100644 remote_calendar_test.go create mode 100644 serialize.go create mode 100644 serialize_test.go create mode 100644 timestamp.go create mode 100644 util.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5bd49b --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# opentimestamps + +Go port of https://github.com/opentimestamps/python-opentimestamps. + +Copied from https://github.com/BlockchainSource/go-opentimestamps. + +# Done + +* Byte-level serialization format +* Timestamp parsing +* Creating pending timestamps +* Upgrading pending timestamps +* Bitcoin Timestamp verification + +# To do + +* Support for multiple timestamp servers +* Proper timestamp merging (on upgrade) +* More conformant serialization (sorting) + +# License + +Apache 2.0 diff --git a/attestations.go b/attestations.go new file mode 100644 index 0000000..5e3822f --- /dev/null +++ b/attestations.go @@ -0,0 +1,183 @@ +package opentimestamps + +import ( + "bytes" + "fmt" +) + +const ( + attestationTagSize = 8 + attestationMaxPayloadSize = 8192 + pendingAttestationMaxUriLength = 1000 +) + +var ( + bitcoinAttestationTag = mustDecodeHex("0588960d73d71901") + pendingAttestationTag = mustDecodeHex("83dfe30d2ef90c8e") +) + +type Attestation interface { + tag() []byte + decode(*deserializationContext) (Attestation, error) + encode(*serializationContext) error +} + +type baseAttestation struct { + fixedTag []byte +} + +func (b *baseAttestation) tag() []byte { + return b.fixedTag +} + +type pendingAttestation struct { + baseAttestation + uri string +} + +func newPendingAttestation() *pendingAttestation { + return &pendingAttestation{ + baseAttestation: baseAttestation{ + fixedTag: pendingAttestationTag, + }, + } +} + +func (p *pendingAttestation) decode( + ctx *deserializationContext, +) (Attestation, error) { + uri, err := ctx.readVarBytes(0, pendingAttestationMaxUriLength) + if err != nil { + return nil, err + } + // TODO utf8 checks + ret := *p + ret.uri = string(uri) + return &ret, nil +} + +func (p *pendingAttestation) encode(ctx *serializationContext) error { + return ctx.writeVarBytes([]byte(p.uri)) +} + +func (p *pendingAttestation) String() string { + return fmt.Sprintf("VERIFY PendingAttestation(url=%s)", p.uri) +} + +type BitcoinAttestation struct { + baseAttestation + Height uint64 +} + +func newBitcoinAttestation() *BitcoinAttestation { + return &BitcoinAttestation{ + baseAttestation: baseAttestation{bitcoinAttestationTag}, + } +} + +func (b *BitcoinAttestation) String() string { + return fmt.Sprintf("VERIFY BitcoinAttestation(height=%d)", b.Height) +} + +func (b *BitcoinAttestation) decode( + ctx *deserializationContext, +) (Attestation, error) { + height, err := ctx.readVarUint() + if err != nil { + return nil, err + } + ret := *b + ret.Height = height + return &ret, nil +} + +func (b *BitcoinAttestation) encode(ctx *serializationContext) error { + return ctx.writeVarUint(uint64(b.Height)) +} + +const hashMerkleRootSize = 32 + +// +func (b *BitcoinAttestation) VerifyAgainstBlockHash( + digest, blockHash []byte, +) error { + if len(digest) != hashMerkleRootSize { + return fmt.Errorf("invalid digest size %d", len(digest)) + } + if !bytes.Equal(digest, blockHash) { + return fmt.Errorf( + "hash mismatch digest=%x blockHash=%x", + digest, blockHash, + ) + } + return nil +} + +// This is a catch-all for when we don't know how to parse it +type unknownAttestation struct { + tagBytes []byte + bytes []byte +} + +func (u unknownAttestation) tag() []byte { + return u.tagBytes +} + +func (unknownAttestation) decode(*deserializationContext) (Attestation, error) { + panic("not implemented") +} + +func (unknownAttestation) encode(*serializationContext) error { + panic("not implemented") +} + +func (u unknownAttestation) String() string { + return fmt.Sprintf("UnknownAttestation(bytes=%q)", u.bytes) +} + +var attestations []Attestation = []Attestation{ + newPendingAttestation(), + newBitcoinAttestation(), +} + +func encodeAttestation(ctx *serializationContext, att Attestation) error { + if err := ctx.writeBytes(att.tag()); err != nil { + return err + } + buf := &bytes.Buffer{} + if err := att.encode(&serializationContext{buf}); err != nil { + return err + } + return ctx.writeVarBytes(buf.Bytes()) +} + +func ParseAttestation(ctx *deserializationContext) (Attestation, error) { + tag, err := ctx.readBytes(attestationTagSize) + if err != nil { + return nil, err + } + + attBytes, err := ctx.readVarBytes( + 0, attestationMaxPayloadSize, + ) + if err != nil { + return nil, err + } + attCtx := newDeserializationContext( + bytes.NewBuffer(attBytes), + ) + + for _, a := range attestations { + if bytes.Equal(tag, a.tag()) { + att, err := a.decode(attCtx) + if err != nil { + return nil, err + } + if !attCtx.assertEOF() { + return nil, fmt.Errorf("expected EOF in attCtx") + } + return att, nil + } + } + return unknownAttestation{tag, attBytes}, nil +} diff --git a/client/bitcoin.go b/client/bitcoin.go new file mode 100644 index 0000000..76a8171 --- /dev/null +++ b/client/bitcoin.go @@ -0,0 +1,99 @@ +package client + +import ( + "fmt" + "math" + "time" + + "github.com/fiatjaf/opentimestamps" + "github.com/btcsuite/btcd/rpcclient" +) + +// A BitcoinAttestationVerifier uses a bitcoin RPC connection to verify bitcoin +// headers. +type BitcoinAttestationVerifier struct { + btcrpcClient *rpcclient.Client +} + +func NewBitcoinAttestationVerifier( + c *rpcclient.Client, +) *BitcoinAttestationVerifier { + return &BitcoinAttestationVerifier{c} +} + +// VerifyAttestation checks a BitcoinAttestation using a given hash digest. It +// returns the time of the block if the verification succeeds, an error +// otherwise. +func (v *BitcoinAttestationVerifier) VerifyAttestation( + digest []byte, a *opentimestamps.BitcoinAttestation, +) (*time.Time, error) { + if a.Height > math.MaxInt64 { + return nil, fmt.Errorf("illegal block height") + } + blockHash, err := v.btcrpcClient.GetBlockHash(int64(a.Height)) + if err != nil { + return nil, err + } + h, err := v.btcrpcClient.GetBlockHeader(blockHash) + if err != nil { + return nil, err + } + + merkleRootBytes := h.MerkleRoot[:] + err = a.VerifyAgainstBlockHash(digest, merkleRootBytes) + if err != nil { + return nil, err + } + utc := h.Timestamp.UTC() + + return &utc, nil +} + +// A BitcoinVerification is the result of verifying a BitcoinAttestation +type BitcoinVerification struct { + Timestamp *opentimestamps.Timestamp + Attestation *opentimestamps.BitcoinAttestation + AttestationTime *time.Time + Error error +} + +// BitcoinVerifications returns the all bitcoin attestation results for the +// timestamp. +func (v *BitcoinAttestationVerifier) BitcoinVerifications( + t *opentimestamps.Timestamp, +) (res []BitcoinVerification) { + t.Walk(func(ts *opentimestamps.Timestamp) { + for _, att := range ts.Attestations { + btcAtt, ok := att.(*opentimestamps.BitcoinAttestation) + if !ok { + continue + } + attTime, err := v.VerifyAttestation(ts.Message, btcAtt) + res = append(res, BitcoinVerification{ + Timestamp: ts, + Attestation: btcAtt, + AttestationTime: attTime, + Error: err, + }) + } + }) + return res +} + +// Verify returns the earliest bitcoin-attested time, or nil if none can be +// found or verified successfully. +func (v *BitcoinAttestationVerifier) Verify( + t *opentimestamps.Timestamp, +) (ret *time.Time, err error) { + res := v.BitcoinVerifications(t) + for _, r := range res { + if r.Error != nil { + err = r.Error + continue + } + if ret == nil || r.AttestationTime.Before(*ret) { + ret = r.AttestationTime + } + } + return +} diff --git a/client/bitcoin_test.go b/client/bitcoin_test.go new file mode 100644 index 0000000..ce8af27 --- /dev/null +++ b/client/bitcoin_test.go @@ -0,0 +1,83 @@ +package client + +import ( + "fmt" + "net/url" + "os" + "testing" + "time" + + "github.com/fiatjaf/opentimestamps" + "github.com/btcsuite/btcd/rpcclient" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const envvarRPCURL = "GOTS_TEST_BITCOIN_RPC" + +func newTestBTCConn() (*rpcclient.Client, error) { + val := os.Getenv(envvarRPCURL) + if val == "" { + return nil, fmt.Errorf("envvar %q unset", envvarRPCURL) + } + connData, err := url.Parse(val) + if err != nil { + return nil, fmt.Errorf( + "could not parse %q=%q: %v", envvarRPCURL, val, err, + ) + } + + host := connData.Host + if connData.User == nil { + return nil, fmt.Errorf("no Userinfo in parsed url") + } + username := connData.User.Username() + password, ok := connData.User.Password() + if !ok { + return nil, fmt.Errorf("no password given in RPC URL") + } + + connCfg := &rpcclient.ConnConfig{ + Host: host, + User: username, + Pass: password, + HTTPPostMode: true, + DisableTLS: true, + } + return rpcclient.New(connCfg, nil) +} + +func TestVerifyHelloWorld(t *testing.T) { + if os.Getenv(envvarRPCURL) == "" { + t.Skipf("envvar %s unset, skipping", envvarRPCURL) + } + + // Format RFC3339 + expectedTime := "2015-05-28T15:41:18Z" + + helloWorld, err := opentimestamps.NewDetachedTimestampFromPath( + "../../examples/hello-world.txt.ots", + ) + require.NoError(t, err) + ts := helloWorld.Timestamp + + btcConn, err := newTestBTCConn() + require.NoError(t, err) + + verifier := BitcoinAttestationVerifier{btcConn} + + // using BitcoinVerifications() + results := verifier.BitcoinVerifications(ts) + assert.Equal(t, 1, len(results)) + result0 := results[0] + require.NoError(t, result0.Error) + assert.Equal( + t, expectedTime, result0.AttestationTime.Format(time.RFC3339), + ) + + // using Verify() + verifiedTime, err := verifier.Verify(ts) + require.NoError(t, err) + require.NotNil(t, verifiedTime) + assert.Equal(t, expectedTime, verifiedTime.Format(time.RFC3339)) +} diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..113e7b8 --- /dev/null +++ b/commands.go @@ -0,0 +1,26 @@ +package opentimestamps + +import ( + "crypto/sha256" + "io" + "os" +) + +func CreateDetachedTimestampForFile( + path string, cal *RemoteCalendar, +) (*DetachedTimestamp, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + hasher := sha256.New() + if _, err := io.Copy(hasher, f); err != nil { + return nil, err + } + digest := hasher.Sum([]byte{}) + ts, err := cal.Submit(digest) + if err != nil { + return nil, err + } + return NewDetachedTimestamp(*opSHA256, digest, ts) +} diff --git a/detached_timestamp.go b/detached_timestamp.go new file mode 100644 index 0000000..9adaa61 --- /dev/null +++ b/detached_timestamp.go @@ -0,0 +1,100 @@ +package opentimestamps + +import ( + "bytes" + "fmt" + "io" + "os" +) + +var fileHeaderMagic = []byte( + "\x00OpenTimestamps\x00\x00Proof\x00\xbf\x89\xe2\xe8\x84\xe8\x92\x94", +) + +const ( + minFileDigestLength = 20 + maxFileDigestLength = 32 + fileMajorVersion = 1 +) + +type DetachedTimestamp struct { + HashOp cryptOp + FileHash []byte + Timestamp *Timestamp +} + +func (d *DetachedTimestamp) Dump() string { + w := &bytes.Buffer{} + fmt.Fprintf( + w, "File %s hash: %x\n", d.HashOp.name, d.Timestamp.Message, + ) + fmt.Fprint(w, d.Timestamp.Dump()) + return w.String() +} + +func (d *DetachedTimestamp) encode(ctx *serializationContext) error { + if err := ctx.writeBytes(fileHeaderMagic); err != nil { + return err + } + if err := ctx.writeVarUint(fileMajorVersion); err != nil { + return err + } + if err := d.HashOp.encode(ctx); err != nil { + return err + } + if err := ctx.writeBytes(d.FileHash); err != nil { + return err + } + return d.Timestamp.encode(ctx) +} + +func (d *DetachedTimestamp) WriteToStream(w io.Writer) error { + return d.encode(&serializationContext{w}) +} + +func NewDetachedTimestamp( + hashOp cryptOp, fileHash []byte, ts *Timestamp, +) (*DetachedTimestamp, error) { + if len(fileHash) != hashOp.digestLength { + return nil, fmt.Errorf( + "op %v expects %d byte digest, got %d", + hashOp, hashOp.digestLength, len(fileHash), + ) + } + return &DetachedTimestamp{hashOp, fileHash, ts}, nil +} + +func NewDetachedTimestampFromReader(r io.Reader) (*DetachedTimestamp, error) { + ctx := newDeserializationContext(r) + if err := ctx.assertMagic([]byte(fileHeaderMagic)); err != nil { + return nil, err + } + major, err := ctx.readVarUint() + if err != nil { + return nil, err + } + if major != uint64(fileMajorVersion) { + return nil, fmt.Errorf("unexpected major version %d", major) + } + fileHashOp, err := parseCryptOp(ctx) + if err != nil { + return nil, err + } + fileHash, err := ctx.readBytes(fileHashOp.digestLength) + if err != nil { + return nil, err + } + ts, err := newTimestampFromContext(ctx, fileHash) + if err != nil { + return nil, err + } + return &DetachedTimestamp{*fileHashOp, fileHash, ts}, nil +} + +func NewDetachedTimestampFromPath(p string) (*DetachedTimestamp, error) { + f, err := os.Open(p) + if err != nil { + return nil, err + } + return NewDetachedTimestampFromReader(f) +} diff --git a/detached_timestamp_test.go b/detached_timestamp_test.go new file mode 100644 index 0000000..38e6f8a --- /dev/null +++ b/detached_timestamp_test.go @@ -0,0 +1,110 @@ +package opentimestamps + +import ( + "bytes" + "encoding/hex" + "io/ioutil" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func examplePaths() []string { + matches, err := filepath.Glob("../examples/*ots") + if err != nil { + panic(err) + } + return matches +} + +func containsUnknownAttestation(ts *Timestamp) (res bool) { + ts.Walk(func(subTs *Timestamp) { + for _, att := range subTs.Attestations { + if _, ok := att.(unknownAttestation); ok { + res = true + } + } + }) + return +} + +func TestDecodeHelloWorld(t *testing.T) { + dts, err := NewDetachedTimestampFromPath( + "../examples/hello-world.txt.ots", + ) + assert.NoError(t, err) + + attCount := 0 + checkAttestation := func(ts *Timestamp, att Attestation) { + assert.Equal(t, 0, attCount) + + expectedAtt := newBitcoinAttestation() + expectedAtt.Height = 358391 + assert.Equal(t, expectedAtt, att) + + // If ts.Message is correct, opcode parsing and execution should + // have succeeded. + assert.Equal(t, + "007ee445d23ad061af4a36b809501fab1ac4f2d7e7a739817dd0cbb7ec661b8a", + hex.EncodeToString(ts.Message), + ) + + attCount += 1 + } + + dts.Timestamp.Walk(func(ts *Timestamp) { + for _, att := range ts.Attestations { + // this should be called exactly once + checkAttestation(ts, att) + } + }) + + assert.Equal(t, 1, attCount) +} + +func TestDecodeEncodeAll(t *testing.T) { + for _, path := range examplePaths() { + t.Log(path) + dts, err := NewDetachedTimestampFromPath(path) + assert.NoError(t, err, path) + + if containsUnknownAttestation(dts.Timestamp) { + t.Logf("skipping encode cycle: unknownAttestation") + continue + } + + buf := &bytes.Buffer{} + err = dts.Timestamp.encode(&serializationContext{buf}) + if !assert.NoError(t, err, path) { + continue + } + + buf = bytes.NewBuffer(buf.Bytes()) + ts1, err := NewTimestampFromReader(buf, dts.Timestamp.Message) + if !assert.NoError(t, err, path) { + continue + } + + dts1, err := NewDetachedTimestamp( + dts.HashOp, dts.FileHash, ts1, + ) + if !assert.NoError(t, err) { + continue + } + + dts1Target := &bytes.Buffer{} + err = dts1.WriteToStream(dts1Target) + if !assert.NoError(t, err) { + continue + } + + orgBytes, err := ioutil.ReadFile(path) + if !assert.NoError(t, err) { + continue + } + + assert.Equal(t, orgBytes, dts1Target.Bytes()) + t.Log("encode cycle success") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cc85c5d --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module github.com/fiatjaf/opentimestamps + +go 1.21 + +require ( + github.com/btcsuite/btcd v0.23.4 + github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.8.4 + golang.org/x/crypto v0.13.0 +) + +require ( + github.com/btcsuite/btcd/btcec/v2 v2.1.3 // indirect + github.com/btcsuite/btcd/btcutil v1.1.0 // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect + github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect + github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect + github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/crypto/blake256 v1.0.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.12.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..659ef97 --- /dev/null +++ b/go.sum @@ -0,0 +1,113 @@ +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= +github.com/btcsuite/btcd v0.23.4 h1:IzV6qqkfwbItOS/sg/aDfPDsjPP8twrCOE2R93hxMlQ= +github.com/btcsuite/btcd v0.23.4/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY= +github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= +github.com/btcsuite/btcd/btcec/v2 v2.1.3 h1:xM/n3yIhHAhHy04z4i43C8p4ehixJZMsnrVJkgl+MTE= +github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= +github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= +github.com/btcsuite/btcd/btcutil v1.1.0 h1:MO4klnGY+EWJdoWF12Wkuf4AWDBPMpZNeN/jRLrklUU= +github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f h1:bAs4lUbRJpnnkd9VhRV3jjAVU7DJVjMaK+IsvSeZvFo= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd h1:R/opQEbFEy9JGkIguV40SvRY1uliPX8ifOvi6ICsFCw= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/operations.go b/operations.go new file mode 100644 index 0000000..3f4d090 --- /dev/null +++ b/operations.go @@ -0,0 +1,220 @@ +package opentimestamps + +import ( + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "fmt" + + "golang.org/x/crypto/ripemd160" +) + +const maxResultLength = 4096 + +type ( + unaryMsgOp func(message []byte) ([]byte, error) + binaryMsgOp func(message, argument []byte) ([]byte, error) +) + +// msgAppend returns the concatenation of msg and arg +func msgAppend(msg, arg []byte) (res []byte, err error) { + res = append(res, msg...) + res = append(res, arg...) + return +} + +// msgPrepend returns the concatenation of arg and msg +func msgPrepend(msg, arg []byte) (res []byte, err error) { + res = append(res, arg...) + res = append(res, msg...) + return +} + +// msgReverse returns the reversed msg. Deprecated. +func msgReverse(msg []byte) ([]byte, error) { + if len(msg) == 0 { + return nil, fmt.Errorf("empty input invalid for msgReverse") + } + res := make([]byte, len(msg)) + for i, b := range msg { + res[len(res)-i-1] = b + } + return res, nil +} + +func msgHexlify(msg []byte) ([]byte, error) { + if len(msg) == 0 { + return nil, fmt.Errorf("empty input invalid for msgHexlify") + } + return []byte(hex.EncodeToString(msg)), nil +} + +func msgSHA1(msg []byte) ([]byte, error) { + res := sha1.Sum(msg) + return res[:], nil +} + +func msgRIPEMD160(msg []byte) ([]byte, error) { + h := ripemd160.New() + _, err := h.Write(msg) + if err != nil { + return nil, err + } + return h.Sum([]byte{}), nil +} + +func msgSHA256(msg []byte) ([]byte, error) { + res := sha256.Sum256(msg) + return res[:], nil +} + +type opCode interface { + match(byte) bool + decode(*deserializationContext) (opCode, error) + encode(*serializationContext) error + apply(message []byte) ([]byte, error) +} + +type op struct { + tag byte + name string +} + +func (o op) match(tag byte) bool { + return o.tag == tag +} + +type unaryOp struct { + op + msgOp unaryMsgOp +} + +func newUnaryOp(tag byte, name string, msgOp unaryMsgOp) *unaryOp { + return &unaryOp{op{tag: tag, name: name}, msgOp} +} + +func (u *unaryOp) String() string { + return u.name +} + +func (u *unaryOp) decode(ctx *deserializationContext) (opCode, error) { + ret := *u + return &ret, nil +} + +func (u *unaryOp) encode(ctx *serializationContext) error { + return ctx.writeByte(u.tag) +} + +func (u *unaryOp) apply(message []byte) ([]byte, error) { + return u.msgOp(message) +} + +// Crypto operations +// These are hash ops that define a digest length +type cryptOp struct { + unaryOp + digestLength int +} + +func newCryptOp( + tag byte, name string, msgOp unaryMsgOp, digestLength int, +) *cryptOp { + return &cryptOp{ + unaryOp: *newUnaryOp(tag, name, msgOp), + digestLength: digestLength, + } +} + +func (c *cryptOp) decode(ctx *deserializationContext) (opCode, error) { + u, err := c.unaryOp.decode(ctx) + if err != nil { + return nil, err + } + return &cryptOp{*u.(*unaryOp), c.digestLength}, nil +} + +// Binary operations +// We decode an extra varbyte argument and use it in apply() + +type binaryOp struct { + op + msgOp binaryMsgOp + argument []byte +} + +func newBinaryOp(tag byte, name string, msgOp binaryMsgOp) *binaryOp { + return &binaryOp{ + op: op{tag: tag, name: name}, + msgOp: msgOp, + argument: nil, + } +} + +func (b *binaryOp) decode(ctx *deserializationContext) (opCode, error) { + arg, err := ctx.readVarBytes(0, maxResultLength) + if err != nil { + return nil, err + } + if len(arg) == 0 { + return nil, fmt.Errorf("empty argument invalid for binaryOp") + } + ret := *b + ret.argument = arg + return &ret, nil +} + +func (b *binaryOp) encode(ctx *serializationContext) error { + if err := ctx.writeByte(b.tag); err != nil { + return err + } + return ctx.writeVarBytes(b.argument) +} + +func (b *binaryOp) apply(message []byte) ([]byte, error) { + return b.msgOp(message, b.argument) +} + +func (b *binaryOp) String() string { + return fmt.Sprintf("%s %x", b.name, b.argument) +} + +var ( + opAppend = newBinaryOp(0xf0, "APPEND", msgAppend) + opPrepend = newBinaryOp(0xf1, "PREPEND", msgPrepend) + opReverse = newUnaryOp(0xf2, "REVERSE", msgReverse) + opHexlify = newUnaryOp(0xf3, "HEXLIFY", msgHexlify) + opSHA1 = newCryptOp(0x02, "SHA1", msgSHA1, 20) + opRIPEMD160 = newCryptOp(0x03, "RIPEMD160", msgRIPEMD160, 20) + opSHA256 = newCryptOp(0x08, "SHA256", msgSHA256, 32) +) + +var opCodes []opCode = []opCode{ + opAppend, opPrepend, opReverse, opHexlify, opSHA1, opRIPEMD160, + opSHA256, +} + +func parseOp(ctx *deserializationContext, tag byte) (opCode, error) { + for _, op := range opCodes { + if op.match(tag) { + return op.decode(ctx) + } + } + return nil, fmt.Errorf("could not decode tag %02x", tag) +} + +func parseCryptOp(ctx *deserializationContext) (*cryptOp, error) { + tag, err := ctx.readByte() + if err != nil { + return nil, err + } + op, err := parseOp(ctx, tag) + if err != nil { + return nil, err + } + if cryptOp, ok := op.(*cryptOp); ok { + return cryptOp, nil + } else { + return nil, fmt.Errorf("expected cryptOp, got %#v", op) + } +} diff --git a/operations_test.go b/operations_test.go new file mode 100644 index 0000000..c53df3d --- /dev/null +++ b/operations_test.go @@ -0,0 +1,78 @@ +package opentimestamps + +import ( + "encoding/hex" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMsgAppend(t *testing.T) { + msg := []byte("123") + res, err := msgAppend(msg, []byte("456")) + assert.NoError(t, err) + assert.Equal(t, "123456", string(res)) + // make sure changes to input msg don't affect output + msg[0] = byte('0') + assert.Equal(t, "123456", string(res)) +} + +func TestMsgPrepend(t *testing.T) { + msg := []byte("123") + res, err := msgPrepend(msg, []byte("abc")) + assert.NoError(t, err) + assert.Equal(t, "abc123", string(res)) + // make sure changes to input msg don't affect output + msg[0] = byte('0') + assert.Equal(t, "abc123", string(res)) +} + +func TestMsgReverse(t *testing.T) { + _, err := msgReverse([]byte{}) + assert.Error(t, err) + res, err := msgReverse([]byte{1, 2, 3}) + assert.NoError(t, err) + assert.Equal(t, []byte{3, 2, 1}, res) +} + +func TestMsgHexlify(t *testing.T) { + _, err := msgHexlify([]byte{}) + assert.Error(t, err) + res, err := msgHexlify([]byte{1, 2, 3, 0xff}) + assert.NoError(t, err) + assert.Equal(t, []byte("010203ff"), res) +} + +func TestMsgSHA1(t *testing.T) { + out, err := msgSHA1([]byte{}) + assert.NoError(t, err) + assert.Equal(t, + "da39a3ee5e6b4b0d3255bfef95601890afd80709", + hex.EncodeToString(out), + ) +} + +func TestMsgSHA256(t *testing.T) { + out, err := msgSHA256([]byte{}) + assert.NoError(t, err) + assert.Equal(t, + "e3b0c44298fc1c149afbf4c8996fb924"+ + "27ae41e4649b934ca495991b7852b855", + hex.EncodeToString(out), + ) +} + +func TestRIPEMD160(t *testing.T) { + out, err := msgRIPEMD160([]byte{}) + assert.Equal(t, + "9c1185a5c5e9fc54612808977ee8f548b2258d31", + hex.EncodeToString(out), + ) + + out, err = msgRIPEMD160(out) + assert.NoError(t, err) + assert.Equal(t, + "38bbc57e4cbe8b6a1d2c999ef62503e0a6e58109", + hex.EncodeToString(out), + ) +} diff --git a/remote_calendar.go b/remote_calendar.go new file mode 100644 index 0000000..7d5347a --- /dev/null +++ b/remote_calendar.go @@ -0,0 +1,146 @@ +package opentimestamps + +import ( + "bytes" + "encoding/hex" + "fmt" + "io/ioutil" + "net/http" + "net/http/httputil" + "strings" + + "github.com/sirupsen/logrus" +) + +const userAgent = "go-opentimestamps" + +const dumpResponse = false + +type RemoteCalendar struct { + baseURL string + client *http.Client + log *logrus.Logger +} + +func NewRemoteCalendar(baseURL string) (*RemoteCalendar, error) { + // FIXME remove this + if baseURL == "localhost" { + baseURL = "http://localhost:14788" + } + // TODO validate url + if !strings.HasSuffix(baseURL, "/") { + baseURL += "/" + } + return &RemoteCalendar{ + baseURL, + http.DefaultClient, + logrus.New(), + }, nil +} + +// Check response status, return informational error message if +// status is not `200 OK`. +func checkStatusOK(resp *http.Response) error { + if resp.StatusCode == http.StatusOK { + return nil + } + errMsg := fmt.Sprintf("unexpected response: %q", resp.Status) + if resp.Body == nil { + return fmt.Errorf("%s (body=nil)", errMsg) + } + defer resp.Body.Close() + bodyBytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("%s (bodyErr=%v)", errMsg, err) + } else { + return fmt.Errorf("%s (body=%q)", errMsg, bodyBytes) + } +} + +func (c *RemoteCalendar) do(r *http.Request) (*http.Response, error) { + r.Header.Add("Accept", "application/vnd.opentimestamps.v1") + r.Header.Add("User-Agent", userAgent) + c.log.Debugf("> %s %s", r.Method, r.URL) + resp, err := c.client.Do(r) + if err != nil { + c.log.Errorf("> %s %s error: %v", r.Method, r.URL, err) + return resp, err + } + c.log.Debugf("< %s %s - %v", r.Method, r.URL, resp.Status) + if dumpResponse { + bytes, err := httputil.DumpResponse(resp, true) + if err == nil { + c.log.Debugf("response dump:%s ", bytes) + } + } + return resp, err +} + +func (c *RemoteCalendar) url(path string) string { + return c.baseURL + path +} + +func (c *RemoteCalendar) Submit(digest []byte) (*Timestamp, error) { + body := bytes.NewBuffer(digest) + req, err := http.NewRequest("POST", c.url("digest"), body) + if err != nil { + return nil, err + } + resp, err := c.do(req) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("expected 200, got %v", resp.Status) + } + return NewTimestampFromReader(resp.Body, digest) +} + +func (c *RemoteCalendar) GetTimestamp(commitment []byte) (*Timestamp, error) { + url := c.url("timestamp/" + hex.EncodeToString(commitment)) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + resp, err := c.do(req) + if err != nil { + return nil, err + } + if err := checkStatusOK(resp); err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + return NewTimestampFromReader(resp.Body, commitment) +} + +type PendingTimestamp struct { + Timestamp *Timestamp + PendingAttestation *pendingAttestation +} + +func (p PendingTimestamp) Upgrade() (*Timestamp, error) { + cal, err := NewRemoteCalendar(p.PendingAttestation.uri) + if err != nil { + return nil, err + } + return cal.GetTimestamp(p.Timestamp.Message) +} + +func PendingTimestamps(ts *Timestamp) (res []PendingTimestamp) { + ts.Walk(func(ts *Timestamp) { + for _, att := range ts.Attestations { + p, ok := att.(*pendingAttestation) + if !ok { + continue + } + attCopy := *p + res = append(res, PendingTimestamp{ts, &attCopy}) + } + }) + return +} diff --git a/remote_calendar_test.go b/remote_calendar_test.go new file mode 100644 index 0000000..b6825ba --- /dev/null +++ b/remote_calendar_test.go @@ -0,0 +1,71 @@ +package opentimestamps + +import ( + "crypto/sha256" + "fmt" + "os" + "testing" + "time" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + calendarServerEnvvar = "GOTS_TEST_CALENDAR_SERVER" + bitcoinRegtestEnvvar = "GOTS_TEST_BITCOIN_REGTEST_SERVER" +) + +func newTestCalendar(url string) *RemoteCalendar { + logrus.SetLevel(logrus.DebugLevel) + cal, err := NewRemoteCalendar(url) + if err != nil { + panic("could not create test calendar") + } + cal.log.Level = logrus.DebugLevel + return cal +} + +func newTestDigest(in string) []byte { + hash := sha256.Sum256([]byte(in)) + return hash[:] +} + +func TestRemoteCalendarExample(t *testing.T) { + dts, err := NewDetachedTimestampFromPath( + "../examples/two-calendars.txt.ots", + ) + require.NoError(t, err) + + pts := PendingTimestamps(dts.Timestamp) + assert.Equal(t, 2, len(pts)) + for _, pt := range pts { + ts, err := pt.Upgrade() + assert.NoError(t, err) + fmt.Print(ts.Dump()) + } +} + +func TestRemoteCalendarRoundTrip(t *testing.T) { + calendarServer := os.Getenv(calendarServerEnvvar) + if calendarServer == "" { + t.Skipf("%q not set, skipping test", calendarServerEnvvar) + } + cal := newTestCalendar(calendarServer) + ts, err := cal.Submit(newTestDigest("Hello, World!")) + require.NoError(t, err) + require.NotNil(t, ts) + + // TODO call rpcclient generateblock 100 + + // FIXME possible opentimestamps-server bug? + // wait until attestation has been aggregated + time.Sleep(2 * time.Second) + + for _, pts := range PendingTimestamps(ts) { + ts, err := pts.Upgrade() + assert.NoError(t, err) + _ = ts + } +} diff --git a/serialize.go b/serialize.go new file mode 100644 index 0000000..85e677f --- /dev/null +++ b/serialize.go @@ -0,0 +1,206 @@ +package opentimestamps + +import ( + "bufio" + "bytes" + "fmt" + "io" + "math" +) + +// serializationContext helps encoding values in the ots format +type serializationContext struct { + w io.Writer +} + +// newSerializationContext returns a serializationContext for a writer +func newSerializationContext(w io.Writer) *serializationContext { + return &serializationContext{w} +} + +// writeBytes writes the raw bytes to the underlying writer +func (s serializationContext) writeBytes(b []byte) error { + // number of bytes can be ignored + // if it is equal len(b) then err is nil + _, err := s.w.Write(b) + if err != nil { + return err + } + return nil +} + +// writeByte writes a single byte +func (s serializationContext) writeByte(b byte) error { + return s.writeBytes([]byte{b}) +} + +// writeBool encodes and writes a boolean value +func (s serializationContext) writeBool(b bool) error { + if b { + return s.writeByte(0xff) + } else { + return s.writeByte(0x00) + } +} + +// writeVarUint encodes and writes writes a variable-length integer +func (s serializationContext) writeVarUint(v uint64) error { + if v == 0 { + s.writeByte(0x00) + } + for v > 0 { + b := byte(v & 0x7f) + if v > uint64(0x7f) { + b |= 0x80 + } + if err := s.writeByte(b); err != nil { + return err + } + if v <= 0x7f { + break + } + v >>= 7 + } + return nil +} + +// writeVarBytes encodes and writes a variable-length array +func (s serializationContext) writeVarBytes(arr []byte) error { + if err := s.writeVarUint(uint64(len(arr))); err != nil { + return err + } + return s.writeBytes(arr) +} + +// deserializationContext helps decoding values from the ots format +type deserializationContext struct { + r io.Reader +} + +// safety boundary for readBytes +// allocation limit for arrays +const maxReadSize = (1 << 12) + +func (d deserializationContext) dump() string { + arr, _ := d.r.(*bufio.Reader).Peek(512) + return fmt.Sprintf("% x", arr) +} + +// readBytes reads n bytes. +func (d deserializationContext) readBytes(n int) ([]byte, error) { + if n > maxReadSize { + return nil, fmt.Errorf("over maxReadSize: %d", maxReadSize) + } + b := make([]byte, n) + m, err := d.r.Read(b) + if err != nil { + return b, err + } + if n != m { + return b, fmt.Errorf("expected %d bytes, got %d", n, m) + } + return b[:], nil +} + +// readByte reads a single byte. +func (d deserializationContext) readByte() (byte, error) { + arr, err := d.readBytes(1) + if err != nil { + return 0, err + } + return arr[0], nil +} + +// readBool reads a boolean. +func (d deserializationContext) readBool() (bool, error) { + arr, err := d.readBytes(1) + if err != nil { + return false, err + } + switch v := arr[0]; v { + case 0x00: + return false, nil + case 0xff: + return true, nil + default: + return false, fmt.Errorf("unexpected value %x", v) + } +} + +// readVarUint reads a variable-length uint64. +func (d deserializationContext) readVarUint() (uint64, error) { + // NOTE + // the original python implementation has no uint64 limit, but I + // don't think we'll ever need more that that. + val := uint64(0) + shift := uint(0) + for { + b, err := d.readByte() + if err != nil { + return 0, err + } + shifted := uint64(b&0x7f) << shift + // ghetto overflow check + if (shifted >> shift) != uint64(b&0x7f) { + return 0, fmt.Errorf("uint64 overflow") + } + val |= shifted + if b&0x80 == 0 { + return val, nil + } + shift += 7 + } +} + +// readVarBytes reads variable-length number of bytes. +func (d deserializationContext) readVarBytes(minLen, maxLen int) ([]byte, error) { + v, err := d.readVarUint() + if err != nil { + return nil, err + } + if v > math.MaxInt32 { + return nil, fmt.Errorf("int overflow") + } + vint := int(v) + if maxLen < vint || vint < minLen { + return nil, fmt.Errorf( + "varbytes length %d outside range (%d, %d)", + vint, minLen, maxLen, + ) + } + + return d.readBytes(vint) +} + +// assertMagic removes reads the expected bytes from the stream. Returns an +// error if the bytes are unexpected. +func (d deserializationContext) assertMagic(expected []byte) error { + arr, err := d.readBytes(len(expected)) + if err != nil { + return err + } + if !bytes.Equal(expected, arr) { + return fmt.Errorf( + "magic bytes mismatch, expected % x got % x", + expected, arr, + ) + } + return nil +} + +// assertEOF reads a byte and returns true if the end of the reader is reached. +// Careful: the read operation is a side-effect. +func (d deserializationContext) assertEOF() bool { + // Unfortunately we can't always do a zero-byte read here, since some + // reader implementations fail to return EOF. This means assertEOF + _, err := d.readByte() + return err == io.EOF +} + +// newDeserializationContext returns a deserializationContext for a reader +func newDeserializationContext(r io.Reader) *deserializationContext { + // TODO + // bufio is used here to allow debugging via d.dump() + // once this code here is robust enough we can just pass r + return &deserializationContext{bufio.NewReader(r)} +} diff --git a/serialize_test.go b/serialize_test.go new file mode 100644 index 0000000..496933f --- /dev/null +++ b/serialize_test.go @@ -0,0 +1,153 @@ +package opentimestamps + +import ( + "bytes" + "math" + "testing" + + "github.com/stretchr/testify/assert" +) + +func newDeserializationContextFromBytes(in []byte) *deserializationContext { + return newDeserializationContext(bytes.NewBuffer(in)) +} + +func TestReadWrite(t *testing.T) { + magic := []byte("magic") + buf := &bytes.Buffer{} + s := newSerializationContext(buf) + + assert.NoError(t, s.writeBytes([]byte{0x00, 0x01})) + assert.NoError(t, s.writeByte(0x02)) + assert.NoError(t, s.writeBool(true)) + assert.NoError(t, s.writeBool(false)) + assert.NoError(t, s.writeByte(0x03)) + assert.NoError(t, s.writeVarUint(1)) + assert.NoError(t, s.writeBytes([]byte{0x81, 0x00})) + assert.NoError(t, s.writeBytes([]byte{0x81, 0x01})) + assert.NoError(t, s.writeVarUint(0x100)) + assert.NoError(t, s.writeVarUint(uint64(math.MaxUint32)+1)) + assert.NoError(t, s.writeVarUint(math.MaxUint64)) + assert.NoError(t, s.writeBytes([]byte{ + // varunit excess MaxUint64 + 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x01, + })) + assert.NoError(t, s.writeBytes(magic)) + assert.NoError(t, s.writeByte(0)) + assert.NoError(t, s.writeBytes(magic)) + + data := buf.Bytes() + + expectedData := []byte{ + 0x00, 0x01, // bytes [0x00, 0x01] + 0x02, // byte 0x02 + 0xff, // bool true + 0x00, // bool false + 0x03, // bool error + 0x01, // varuint 1 + 0x81, 0x00, // varuint 1 + 0x81, 0x01, // varuint 1 (alternative) + 0x80, 0x02, // varuint 0x100 + + // varunit math.MaxUint32 + 1 + 0x80, 0x80, 0x80, 0x80, 0x10, + + // varunit math.MaxUint64 + 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, + 0xff, 0x01, + + // varunit excess math.MaxUint64 + 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x01, + + // "magic" + 0x6d, 0x61, 0x67, 0x69, 0x63, + // zero + 0x00, + // "magic" + 0x6d, 0x61, 0x67, 0x69, 0x63, + } + + assert.Equal(t, expectedData, data) + + d := newDeserializationContextFromBytes(data) + + { + v, err := d.readBytes(2) + assert.NoError(t, err) + assert.Equal(t, []byte{0x00, 0x01}, v) + } + { + v, err := d.readByte() + assert.NoError(t, err) + assert.Equal(t, byte(0x02), v) + } + { + v, err := d.readBool() + assert.NoError(t, err) + assert.Equal(t, true, v) + } + { + v, err := d.readBool() + assert.NoError(t, err) + assert.Equal(t, false, v) + } + { + _, err := d.readBool() + assert.Error(t, err) + } + { + v, err := d.readVarUint() + assert.NoError(t, err) + assert.Equal(t, uint64(1), v) + } + { + v, err := d.readVarUint() + assert.NoError(t, err) + assert.Equal(t, uint64(1), v) + } + { + v, err := d.readVarUint() + assert.NoError(t, err) + assert.Equal(t, uint64(0x81), v) + } + { + v, err := d.readVarUint() + assert.NoError(t, err) + assert.Equal(t, uint64(0x100), v) + } + { + v, err := d.readVarUint() + assert.NoError(t, err) + assert.Equal(t, uint64(math.MaxUint32)+uint64(1), v) + } + { + v, err := d.readVarUint() + assert.NoError(t, err) + assert.Equal(t, uint64(math.MaxUint64), uint64(v)) + } + { + _, err := d.readVarUint() + assert.Error(t, err) + // read leftover 0x02 + b, err := d.readByte() + assert.NoError(t, err) + assert.Equal(t, byte(0x01), b) + + } + { + assert.NoError(t, d.assertMagic(magic)) + // fails because of in-between 0x00 + assert.Error(t, d.assertMagic(magic)) + } + { + // read leftover byte + _, err := d.readByte() + assert.NoError(t, err) + assert.True(t, d.assertEOF()) + } +} diff --git a/timestamp.go b/timestamp.go new file mode 100644 index 0000000..4780d96 --- /dev/null +++ b/timestamp.go @@ -0,0 +1,193 @@ +package opentimestamps + +import ( + "bytes" + "fmt" + "io" + "strings" +) + +type dumpConfig struct { + showMessage bool + showFlat bool +} + +var defaultDumpConfig dumpConfig = dumpConfig{ + showMessage: true, + showFlat: false, +} + +// A timestampLink with the opCode being the link edge. The reference +// implementation uses a map, but the implementation is a bit complex. A list +// should work as well. +type tsLink struct { + opCode opCode + timestamp *Timestamp +} + +// A Timestamp can contain many attestations and operations. +type Timestamp struct { + Message []byte + Attestations []Attestation + ops []tsLink +} + +// Walk calls the passed function f for this timestamp and all +// downstream timestamps that are chained via operations. +func (t *Timestamp) Walk(f func(t *Timestamp)) { + f(t) + for _, l := range t.ops { + l.timestamp.Walk(f) + } +} + +func (t *Timestamp) encode(ctx *serializationContext) error { + n := len(t.Attestations) + len(t.ops) + if n == 0 { + return fmt.Errorf("cannot encode empty timestamp") + } + prefixAtt := []byte{0x00} + prefixOp := []byte{} + nextNode := func(prefix []byte) error { + n -= 1 + if n > 0 { + return ctx.writeByte(0xff) + } + if len(prefix) > 0 { + return ctx.writeBytes(prefix) + } + return nil + } + // FIXME attestations should be sorted + for _, att := range t.Attestations { + if err := nextNode(prefixAtt); err != nil { + return err + } + if err := encodeAttestation(ctx, att); err != nil { + return err + } + } + // FIXME ops should be sorted + for _, op := range t.ops { + if err := nextNode(prefixOp); err != nil { + return err + } + if err := op.opCode.encode(ctx); err != nil { + return err + } + if err := op.timestamp.encode(ctx); err != nil { + return err + } + } + return nil +} + +func (t *Timestamp) DumpIndent(w io.Writer, indent int, cfg dumpConfig) { + if cfg.showMessage { + fmt.Fprintf(w, strings.Repeat(" ", indent)) + fmt.Fprintf(w, "message %x\n", t.Message) + } + for _, att := range t.Attestations { + fmt.Fprint(w, strings.Repeat(" ", indent)) + fmt.Fprintln(w, att) + } + + for _, tsLink := range t.ops { + fmt.Fprint(w, strings.Repeat(" ", indent)) + fmt.Fprintln(w, tsLink.opCode) + // fmt.Fprint(w, strings.Repeat(" ", indent)) + // if the timestamp is indeed tree-shaped, show it like that + if !cfg.showFlat || len(t.ops) > 1 { + indent += 1 + } + tsLink.timestamp.DumpIndent(w, indent, cfg) + } +} + +func (t *Timestamp) DumpWithConfig(cfg dumpConfig) string { + b := &bytes.Buffer{} + t.DumpIndent(b, 0, cfg) + return b.String() +} + +func (t *Timestamp) Dump() string { + return t.DumpWithConfig(defaultDumpConfig) +} + +func parseTagOrAttestation( + ts *Timestamp, + ctx *deserializationContext, + tag byte, + message []byte, + limit int, +) error { + if tag == 0x00 { + a, err := ParseAttestation(ctx) + if err != nil { + return err + } + ts.Attestations = append(ts.Attestations, a) + } else { + op, err := parseOp(ctx, tag) + if err != nil { + return err + } + newMessage, err := op.apply(message) + if err != nil { + return err + } + nextTs := &Timestamp{Message: newMessage} + err = parse(nextTs, ctx, newMessage, limit-1) + if err != nil { + return err + } + ts.ops = append(ts.ops, tsLink{op, nextTs}) + + } + return nil +} + +func parse( + ts *Timestamp, ctx *deserializationContext, message []byte, limit int, +) error { + if limit == 0 { + return fmt.Errorf("recursion limit") + } + var tag byte + var err error + for { + tag, err = ctx.readByte() + if err != nil { + return err + } + if tag == 0xff { + tag, err = ctx.readByte() + if err != nil { + return err + } + err := parseTagOrAttestation(ts, ctx, tag, message, limit) + if err != nil { + return err + } + } else { + break + } + } + return parseTagOrAttestation(ts, ctx, tag, message, limit) +} + +func newTimestampFromContext( + ctx *deserializationContext, message []byte, +) (*Timestamp, error) { + recursionLimit := 1000 + ts := &Timestamp{Message: message} + err := parse(ts, ctx, message, recursionLimit) + if err != nil { + return nil, err + } + return ts, nil +} + +func NewTimestampFromReader(r io.Reader, message []byte) (*Timestamp, error) { + return newTimestampFromContext(newDeserializationContext(r), message) +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..cd5a1ea --- /dev/null +++ b/util.go @@ -0,0 +1,11 @@ +package opentimestamps + +import "encoding/hex" + +func mustDecodeHex(in string) []byte { + out, err := hex.DecodeString(in) + if err != nil { + panic(err) + } + return out +}