You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
garble/hash.go

241 lines
7.8 KiB
Go

// Copyright (c) 2019, The Garble Authors.
// See LICENSE for licensing information.
package main
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"fmt"
"go/token"
"io"
"os"
"os/exec"
"strings"
)
const buildIDSeparator = "/"
// splitActionID returns the action ID half of a build ID, the first element.
func splitActionID(buildID string) string {
i := strings.Index(buildID, buildIDSeparator)
if i < 0 {
return buildID
}
return buildID[:i]
}
// splitContentID returns the content ID half of a build ID, the last element.
func splitContentID(buildID string) string {
return buildID[strings.LastIndex(buildID, buildIDSeparator)+1:]
}
// decodeHash is the opposite of hashToString, but with a panic for error
// handling since it should never happen.
func decodeHash(str string) []byte {
h, err := base64.RawURLEncoding.DecodeString(str)
if err != nil {
panic(fmt.Sprintf("invalid hash %q: %v", str, err))
}
return h
}
func alterToolVersion(tool string, args []string) error {
cmd := exec.Command(args[0], args[1:]...)
out, err := cmd.Output()
if err != nil {
if err, _ := err.(*exec.ExitError); err != nil {
return fmt.Errorf("%v: %s", err, err.Stderr)
}
return err
}
line := string(bytes.TrimSpace(out)) // no trailing newline
f := strings.Fields(line)
if len(f) < 3 || f[0] != tool || f[1] != "version" || f[2] == "devel" && !strings.HasPrefix(f[len(f)-1], "buildID=") {
return fmt.Errorf("%s -V=full: unexpected output:\n\t%s", args[0], line)
}
var toolID []byte
if f[2] == "devel" {
// On the development branch, use the content ID part of the build ID.
toolID = decodeHash(splitContentID(f[len(f)-1]))
} else {
// For a release, the output is like: "compile version go1.9.1 X:framepointer".
// Use the whole line.
toolID = []byte(line)
}
contentID, err := ownContentID(toolID)
if err != nil {
return fmt.Errorf("cannot obtain garble's own version: %v", err)
}
// The part of the build ID that matters is the last, since it's the
// "content ID" which is used to work out whether there is a need to redo
// the action (build) or not. Since cmd/go parses the last word in the
// output as "buildID=...", we simply add "+garble buildID=_/_/_/${hash}".
// The slashes let us imitate a full binary build ID, but we assume that
// the other components such as the action ID are not necessary, since the
// only reader here is cmd/go and it only consumes the content ID.
fmt.Printf("%s +garble buildID=_/_/_/%s\n", line, contentID)
return nil
}
func ownContentID(toolID []byte) (string, error) {
// We can't rely on the module version to exist, because it's
// missing in local builds without 'go get'.
// For now, use 'go tool buildid' on the binary that's running. Just
// like Go's own cache, we use hex-encoded sha256 sums.
// Once https://github.com/golang/go/issues/37475 is fixed, we
// can likely just use that.
path, err := os.Executable()
if err != nil {
return "", err
}
buildID, err := buildidOf(path)
if err != nil {
return "", err
}
ownID := decodeHash(splitContentID(buildID))
// Join the two content IDs together into a single base64-encoded sha256
// sum. This includes the original tool's content ID, and garble's own
// content ID.
h := sha256.New()
h.Write(toolID)
h.Write(ownID)
// We also need to add the selected options to the full version string,
// because all of them result in different output. We use spaces to
// separate the env vars and flags, to reduce the chances of collisions.
if envGoPrivate != "" {
fmt.Fprintf(h, " GOPRIVATE=%s", envGoPrivate)
}
if opts.GarbleLiterals {
fmt.Fprintf(h, " -literals")
}
if opts.Tiny {
fmt.Fprintf(h, " -tiny")
}
if len(opts.Seed) > 0 {
fmt.Fprintf(h, " -seed=%x", opts.Seed)
}
return hashToString(h.Sum(nil)), nil
}
// hashToString encodes the first 120 bits of a sha256 sum in base64, the same
// format used for elements in a build ID.
func hashToString(h []byte) string {
return base64.RawURLEncoding.EncodeToString(h[:15])
}
func buildidOf(path string) (string, error) {
cmd := exec.Command("go", "tool", "buildid", path)
out, err := cmd.Output()
if err != nil {
if err, _ := err.(*exec.ExitError); err != nil {
return "", fmt.Errorf("%v: %s", err, err.Stderr)
}
return "", err
}
return string(out), nil
}
var (
// Hashed names are base64-encoded.
// Go names can only be letters, numbers, and underscores.
// This means we can use base64's URL encoding, minus '-'.
// Use the URL encoding, replacing '-' with a duplicate 'z'.
// Such a lossy encoding is fine, since we never decode hashes.
nameCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_z"
nameBase64 = base64.NewEncoding(nameCharset)
)
// These funcs mimic the unicode package API, but byte-based since we know
// base64 is all ASCII.
func isDigit(b byte) bool { return '0' <= b && b <= '9' }
func isLower(b byte) bool { return 'a' <= b && b <= 'z' }
func isUpper(b byte) bool { return 'A' <= b && b <= 'Z' }
func toLower(b byte) byte { return b + ('a' - 'A') }
func toUpper(b byte) byte { return b - ('a' - 'A') }
// hashWith returns a hashed version of name, including the provided salt as well as
// opts.Seed into the hash input.
//
// The result is always four bytes long. If the input was a valid identifier,
// the output remains equally exported or unexported. Note that this process is
// reproducible, but not reversible.
func hashWith(salt []byte, name string) string {
obfuscate unexported names like exported ones (#227) In 90fa325da7, the obfuscation logic was changed to use hashes for exported names, but incremental names starting at just one letter for unexported names. Presumably, this was done for the sake of binary size. I argue that this is not a good idea for the default mode for a number of reasons: 1) It makes reversing of stack traces nearly impossible for unexported names, since replacing an obfuscated name "c" with "originalName" would trigger too many false positives by matching single characters. 2) Exported and unexported names aren't different. We need to know how names were obfuscated at a later time in both cases, thanks to use cases like -ldflags=-X. Using short names for one but not the other doesn't make a lot of sense, and makes the logic inconsistent. 3) Shaving off three bytes for unexported names doesn't seem like a huge deal for the default mode, when we already have -tiny to optimize for size. This saves us a bit of work, but most importantly, simplifies the obfuscation state as we no longer need to carry privateNameMap between the compile and link stages. name old time/op new time/op delta Build-8 153ms ± 2% 150ms ± 2% ~ (p=0.065 n=6+6) name old bin-B new bin-B delta Build-8 7.09M ± 0% 7.08M ± 0% -0.24% (p=0.002 n=6+6) name old sys-time/op new sys-time/op delta Build-8 296ms ± 5% 277ms ± 6% -6.50% (p=0.026 n=6+6) name old user-time/op new user-time/op delta Build-8 562ms ± 1% 558ms ± 3% ~ (p=0.329 n=5+6) Note that I do not oppose using short names for both exported and unexported names in the future for -tiny, since reversing of stack traces will by design not work there. The code can be resurrected from the git history if we want to improve -tiny that way in the future, as we'd need to store state in header files again. Another major cleanup we can do here is to no longer use the garbledImports map. From a look at obfuscateImports, we hash a package's import path with its action ID, much like exported names, so we can simply re-do that hashing for the linker's -X flag. garbledImports does have some logic to handle duplicate package names, but it's worth noting that should not affect package paths, as they are always unique. That area of code could probably do with some simplification in the future, too. While at it, make hashWith panic if either parameter is empty. obfuscateImports was hashing the main package path without a salt due to a bug, so we want to catch those in the future. Finally, make some tiny spacing and typo tweaks to the README.
3 years ago
if len(salt) == 0 {
panic("hashWith: empty salt")
}
if name == "" {
panic("hashWith: empty name")
}
use more bits for the obfuscated name hashes (#248) We've been using four base64 characters for obfuscated names for a while. And that has mostly worked, since most packages only have up to a few hundred exported or unexported names at a time. However, we have already encountered two collisions in the wild, which can be reproduced with one seed but not another: [...] PsaN.hQyW is a field, not a method [...] byte is not a type In both of those cases, we happened to run into a collision by chance. And that's not terribly unlikely to begin with; even with just 100 names, the probability of a collision was about 0.03%. It dramatically goes up if there are more names; with 500, we're already around 0.75%. It's clear that four base64 chars is not enough to properly avoid collisions in the vast majority of cases. But how many characters are enough? The target should be that, even with a very large package and lots of names, we should still practically never have a collision. I did some basic estimation with "lots of names" being ten thousand, with "practically never" being a one in a million chance. We need to go all the way up to eight characters to reach that probability. It's entirely possible that 7 or even 6 characters would be enough for most users. However, collisions result in confusing errors which are also hard to reproduce for us unless we can use exactly the same seed and source code for a build. So, play it safe, and use 8 characters. The constant now also has documentation explaining how we arrived at that figure.
3 years ago
// hashLength is the number of base64 characters to use for the final
// hashed name.
// This needs to be long enough to realistically avoid hash collisions,
// but short enough to not bloat binary sizes.
// The namespace for collisions is generally a single package, since
// that's where most hashed names are namespaced to.
// Using a "hash collision" formula, and taking a generous estimate of a
// package having 10k names, we get the following probabilities.
// Most packages will have far fewer names, but some packages are huge,
// especially generated ones.
// We also have slightly fewer bits in practice, since the base64
// charset has 'z' twice, and the first base64 char is coerced into a
// valid Go identifier. So we must be conservative.
// Remember that base64 stores 6 bits per encoded byte.
// The probability numbers are approximated.
//
// length (base64) | length (bits) | collision probability
// -------------------------------------------------------
// 4 24 ~95%
// 5 30 ~4%
// 6 36 ~0.07%
// 7 42 ~0.001%
// 8 48 ~0.00001%
//
// We want collisions to be practically impossible, so we choose 8 to
// end up with a chance of about 1 in a million even when a package has
// thousands of obfuscated names.
const hashLength = 8
d := sha256.New()
d.Write(salt)
d.Write(opts.Seed)
io.WriteString(d, name)
sum := make([]byte, nameBase64.EncodedLen(d.Size()))
nameBase64.Encode(sum, d.Sum(nil))
use more bits for the obfuscated name hashes (#248) We've been using four base64 characters for obfuscated names for a while. And that has mostly worked, since most packages only have up to a few hundred exported or unexported names at a time. However, we have already encountered two collisions in the wild, which can be reproduced with one seed but not another: [...] PsaN.hQyW is a field, not a method [...] byte is not a type In both of those cases, we happened to run into a collision by chance. And that's not terribly unlikely to begin with; even with just 100 names, the probability of a collision was about 0.03%. It dramatically goes up if there are more names; with 500, we're already around 0.75%. It's clear that four base64 chars is not enough to properly avoid collisions in the vast majority of cases. But how many characters are enough? The target should be that, even with a very large package and lots of names, we should still practically never have a collision. I did some basic estimation with "lots of names" being ten thousand, with "practically never" being a one in a million chance. We need to go all the way up to eight characters to reach that probability. It's entirely possible that 7 or even 6 characters would be enough for most users. However, collisions result in confusing errors which are also hard to reproduce for us unless we can use exactly the same seed and source code for a build. So, play it safe, and use 8 characters. The constant now also has documentation explaining how we arrived at that figure.
3 years ago
sum = sum[:hashLength]
// Even if we are hashing a package path, we still want the result to be
// a valid identifier, since we'll use it as the package name too.
if isDigit(sum[0]) {
// Turn "3foo" into "Dfoo".
// Similar to toLower, since uppercase letters go after digits
// in the ASCII table.
sum[0] += 'A' - '0'
}
// Keep the result equally exported or not, if it was an identifier.
if !token.IsIdentifier(name) {
return string(sum)
}
if token.IsExported(name) {
if sum[0] == '_' {
// Turn "_foo" into "Zfoo".
sum[0] = 'Z'
} else if isLower(sum[0]) {
// Turn "afoo" into "Afoo".
sum[0] = toUpper(sum[0])
}
} else {
if isUpper(sum[0]) {
// Turn "Afoo" into "afoo".
sum[0] = toLower(sum[0])
}
}
return string(sum)
}