| // Package checkpoint implements methods to interact with checkpoints |
| // as described below. |
| // |
| // Root is the internal representation of the information needed to |
| // commit to the contents of the tree, and contains the root hash and size. |
| // |
| // When a commitment needs to be sent to other processes (such as a witness or |
| // other log clients), it is put in the form of a checkpoint, which also |
| // includes an "ecosystem identifier". The "ecosystem identifier" defines how |
| // to parse the checkpoint data. This package deals only with the DEFAULT |
| // ecosystem, which has only the information from Root and no additional data. |
| // Support for other ecosystems will be added as needed. |
| // |
| // This checkpoint is signed in a note format (golang.org/x/mod/sumdb/note) |
| // before sending out. An unsigned checkpoint is not a valid commitment and |
| // must not be used. |
| // |
| // There is only a single signature. |
| // Support for multiple signing identities will be added as needed. |
| package checkpoint |
| |
| import ( |
| "crypto/ecdsa" |
| "crypto/sha256" |
| "crypto/x509" |
| "encoding/base64" |
| "encoding/binary" |
| "encoding/pem" |
| "errors" |
| "fmt" |
| "io" |
| "net/http" |
| "net/url" |
| "path" |
| "strconv" |
| "strings" |
| |
| "golang.org/x/mod/sumdb/note" |
| ) |
| |
| const ( |
| // defaultEcosystemID identifies a checkpoint in the DEFAULT ecosystem. |
| defaultEcosystemID = "DEFAULT\n" |
| ) |
| |
| type verifier interface { |
| Verify(msg []byte, sig []byte) bool |
| Name() string |
| KeyHash() uint32 |
| } |
| |
| // EcdsaVerifier verifies a message signature that was signed using ECDSA. |
| type EcdsaVerifier struct { |
| PubKey *ecdsa.PublicKey |
| name string |
| hash uint32 |
| } |
| |
| // Verify returns whether the signature of the message is valid using its |
| // pubKey. |
| func (v EcdsaVerifier) Verify(msg, sig []byte) bool { |
| h := sha256.Sum256(msg) |
| if !ecdsa.VerifyASN1(v.PubKey, h[:], sig) { |
| return false |
| } |
| return true |
| } |
| |
| // KeyHash returns a 4 byte hash of the public key to be used as a hint to the |
| // verifier. |
| func (v EcdsaVerifier) KeyHash() uint32 { |
| return v.hash |
| } |
| |
| // Name returns the name of the key. |
| func (v EcdsaVerifier) Name() string { |
| return v.name |
| } |
| |
| // NewVerifier expects an ECDSA public key in PEM format in a file with the provided path and key name. |
| func NewVerifier(pemKey []byte, name string) (EcdsaVerifier, error) { |
| b, _ := pem.Decode(pemKey) |
| if b == nil || b.Type != "PUBLIC KEY" { |
| return EcdsaVerifier{}, fmt.Errorf("Failed to decode public key, must contain an ECDSA public key in PEM format") |
| } |
| |
| key := b.Bytes |
| sum := sha256.Sum256(key) |
| keyHash := binary.BigEndian.Uint32(sum[:]) |
| |
| pub, err := x509.ParsePKIXPublicKey(key) |
| if err != nil { |
| return EcdsaVerifier{}, fmt.Errorf("Can't parse key: %v", err) |
| } |
| return EcdsaVerifier{ |
| PubKey: pub.(*ecdsa.PublicKey), |
| hash: keyHash, |
| name: name, |
| }, nil |
| } |
| |
| // Root contains the checkpoint data for a DEFAULT ecosystem checkpoint. |
| type Root struct { |
| // Size is the number of entries in the log at this point. |
| Size uint64 |
| // Hash commits to the contents of the entire log. |
| Hash []byte |
| } |
| |
| func parseCheckpoint(ckpt string) (Root, error) { |
| if !strings.HasPrefix(ckpt, defaultEcosystemID) { |
| return Root{}, errors.New("invalid checkpoint - unknown ecosystem, must be DEFAULT") |
| } |
| // Strip the ecosystem ID and parse the rest of the checkpoint. |
| body := ckpt[len(defaultEcosystemID):] |
| // body must contain exactly 2 lines, size and the root hash. |
| l := strings.SplitN(body, "\n", 3) |
| if len(l) != 3 || len(l[2]) != 0 { |
| return Root{}, errors.New("invalid checkpoint - bad format: must have ecosystem id, size and root hash each followed by newline") |
| } |
| size, err := strconv.ParseUint(l[0], 10, 64) |
| if err != nil { |
| return Root{}, fmt.Errorf("invalid checkpoint - cannot read size: %w", err) |
| } |
| rh, err := base64.StdEncoding.DecodeString(l[1]) |
| if err != nil { |
| return Root{}, fmt.Errorf("invalid checkpoint - invalid roothash: %w", err) |
| } |
| return Root{Size: size, Hash: rh}, nil |
| } |
| |
| func getSignedCheckpoint(logURL string) ([]byte, error) { |
| // Sanity check the input url. |
| u, err := url.Parse(logURL) |
| if err != nil { |
| return []byte{}, fmt.Errorf("invalid URL %s: %v", u, err) |
| } |
| |
| u.Path = path.Join(u.Path, "checkpoint.txt") |
| |
| resp, err := http.Get(u.String()) |
| if err != nil { |
| return []byte{}, fmt.Errorf("http.Get(%s): %v", u, err) |
| } |
| defer resp.Body.Close() |
| if code := resp.StatusCode; code != 200 { |
| return []byte{}, fmt.Errorf("http.Get(%s): %s", u, http.StatusText(code)) |
| } |
| |
| return io.ReadAll(resp.Body) |
| } |
| |
| // FromURL verifies the signature and unpacks and returns a Root. |
| // |
| // Validates signature before reading data, using a provided verifier. |
| // Data at `logURL` is the checkpoint and must be in the note format |
| // (golang.org/x/mod/sumdb/note). |
| // |
| // The checkpoint must be in the DEFAULT ecosystem. |
| // |
| // Returns error if the signature fails to verify or if the checkpoint |
| // does not conform to the following format: |
| // []byte("[ecosystem]\n[size]\n[hash]"). |
| func FromURL(logURL string, v verifier) (Root, error) { |
| b, err := getSignedCheckpoint(logURL) |
| if err != nil { |
| return Root{}, fmt.Errorf("failed to get signed checkpoint: %v", err) |
| } |
| |
| n, err := note.Open(b, note.VerifierList(v)) |
| if err != nil { |
| return Root{}, fmt.Errorf("failed to verify note signatures: %v", err) |
| } |
| return parseCheckpoint(n.Text) |
| } |