diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1f9b5ea..8ec73b8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -29,6 +29,9 @@ jobs: run: | git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: tests + run: go test ./... + - name: make run: make diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 532b56d..f9ee87e 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -15,10 +15,10 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 with: - go-version: '1.21' + go-version: stable cache: false - name: golangci-lint uses: golangci/golangci-lint-action@v9 diff --git a/.golangci.yml b/.golangci.yml index 37c0f57..a20419f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -14,12 +14,15 @@ linters: - fatcontext - gocheckcompilerdirectives - gochecksumtype + - gocritic + - gocyclo - godox - gomoddirectives - gomodguard - gosec - gosmopolitan - loggercheck + - maintidx - makezero - misspell - musttag @@ -56,6 +59,7 @@ linters: - perfsprint - maintidx - noctx + - testpackage # enable later - gocritic - staticcheck diff --git a/Makefile b/Makefile index 9500e55..2e34193 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,10 @@ reload-rules: udevadm control --reload udevadm trigger +.PHONY: test +test: + go test ./... + podman: podman run --arch=amd64 --rm --mount type=bind,source=$(CURDIR),target=/src -w /src -it ghcr.io/tillitis/tkey-builder:4 make -j diff --git a/README.md b/README.md index 7f2fa0a..d54d73c 100644 --- a/README.md +++ b/README.md @@ -14,45 +14,31 @@ See [Release notes](RELEASE.md). ## Usage -Get a public key, possibly modifying the key pair by using a User -Supplied Secret, and storing the public key in file `-p pubkey`. +Run with `-h`. See examples below. For details, see manual page in +`doc/tkey-sign.1` and source in +[doc/tkey-sign.scd](doc/tkey-sign.scd). -``` -tkey-sign -G/--getkey [-d/--port device] [-s/--speed speed] -[--uss] [--uss-file secret-file] -p/--public pubkey -``` - -Sign a file, specified with `-m message`, possibly modifying the -measured key pair by using a User Supplied Secret, and storing the -signature in `-x sigfile` or, by default, in `message.sig`. You need -to supply the public key file as well which `tkey-sign` will -automatically verify that it's the expected public key. - -``` -tkey-sign -S/--sign [-d/--port device] [-s speed] -m message -[--uss] [--uss-file secret-file] -p/--public pubkey [-x sig-file] -``` +The key and signature files are compatible with OpenBSD's `signify(1)` +but the way the signing works is not. Instead of signing the entire +file `tkey-sign` signs a digest of the message. The hash algorithm can +be SHA-512 (default) and BLAKE2s. -Verify a signature of file `-m message` with public key in `-p pubkey`. -Signature is by default in `message.sig` but can be specified -with `-x sigfile`. Doesn't need a connected TKey. +In order to be at least slightly compatible with `signify`, +`tkey-sign` when using the SHA-512 digest, does it in a way that is +compatible with: ``` -tkey-sign -V/--verify -m message -p/--public pubkey [-x sigfile] +sha512sum message-file >digestfile +signify -V -m digestfile -x message-file.sig -p key.pub ``` -Alternatively you can use OpenBSD's *signify(1)* to verify the -signature but you need to compute the SHA-512 of the file first and -feed that to the verification. We provide a handy script that does -this: +See the helper script `signify-verify`. -``` -signify-verify message pubkey -``` +NB! This is slightly worrisome since it potentially includes the path +(not necessarily just basename) of the `message-file` into the message +that is signed depending on how you call `sha512sum`. -Exit code is 0 on success and non-zero on failure. - -See the manual page for details. +There is no similar support for BLAKE2s digests. ## Examples @@ -60,6 +46,7 @@ All examples either load the device app automatically or works with an already loaded device app. Store the public key in a file. + ``` $ tkey-sign -G -p key.pub ``` @@ -98,8 +85,8 @@ $ go install github.com/tillitis/tkey-sign-cli/cmd/tkey-sign@latest After this the `tkey-sign` command should be available in your `$GOBIN` directory. -Note that this doesn't set the version and other stuff you get if you -use `make`. +Note that this doesn't include the manual page and doesn't set the +version you get with `make`. ### Building diff --git a/RELEASE.md b/RELEASE.md index eb0dd72..0af815d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,12 @@ # Release notes +## Upcoming release + +- Introduce package signify. Export Signify types to import and export + them to buffers and files. + +- Add support for BLAKE2s hashing. + ## v1.1.1 - Update tkeyclient to v1.3.1 to handle TKey Unlocked (product ID 8) diff --git a/cmd/tkey-sign/file.go b/cmd/tkey-sign/file.go deleted file mode 100644 index de241af..0000000 --- a/cmd/tkey-sign/file.go +++ /dev/null @@ -1,140 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Tillitis AB -// SPDX-License-Identifier: BSD-2-Clause - -package main - -import ( - "bufio" - "bytes" - "encoding/base64" - "encoding/binary" - "errors" - "fmt" - "os" - "strings" -) - -// readBase64 reads the file in filename with base64, decodes it and -// returns a binary representation -func readBase64(filename string) ([]byte, error) { - input, err := os.ReadFile(filename) - if err != nil { - return nil, fmt.Errorf("%w", err) - } - - lines := strings.Split(string(input), "\n") - if len(lines) < 2 { - return nil, fmt.Errorf("too few lines in file %s", filename) - } - - data, err := base64.StdEncoding.DecodeString(lines[1]) - if err != nil { - return nil, fmt.Errorf("could not decode: %w", err) - } - - return data, nil -} - -func readKey(filename string) (*pubKey, error) { - var pub pubKey - - buf, err := readBase64(filename) - if err != nil { - return nil, fmt.Errorf("%w", err) - } - - r := bytes.NewReader(buf) - err = binary.Read(r, binary.BigEndian, &pub) - if err != nil { - return nil, fmt.Errorf("%w", err) - } - - return &pub, nil -} - -func readSig(filename string) (*signature, error) { - var sig signature - - buf, err := readBase64(filename) - if err != nil { - return nil, fmt.Errorf("%w", err) - } - - r := bytes.NewReader(buf) - err = binary.Read(r, binary.BigEndian, &sig) - if err != nil { - return nil, fmt.Errorf("%w", err) - } - - return &sig, nil -} - -// writeBase64 encodes data in base64 and writes it the file given in -// filename. If overwrite is true it overwrites any existing file, -// otherwise it returns an error. -func writeBase64(filename string, data any, comment string, overwrite bool) error { - var buf bytes.Buffer - - err := binary.Write(&buf, binary.BigEndian, data) - if err != nil { - return fmt.Errorf("%w", err) - } - - b64 := base64.StdEncoding.EncodeToString(buf.Bytes()) - b64 += "\n" - - var f *os.File - - f, err = os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o666) - if err != nil { - if os.IsExist(err) && overwrite { - f, err = os.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0o666) - if err != nil { - return fmt.Errorf("%w", err) - } - } else { - return fmt.Errorf("%w", err) - } - } - - defer f.Close() - - _, err = f.Write([]byte(fmt.Sprintf("untrusted comment: %s\n", comment))) - if err != nil { - return fmt.Errorf("%w", err) - } - _, err = f.Write([]byte(b64)) - if err != nil { - return fmt.Errorf("%w", err) - } - - return nil -} - -// writeRetry writes the data in the file given in filename as base64. -// If a file already exists it prompts interactively for permission to -// overwrite the file. -func writeRetry(filename string, data any, comment string) error { - err := writeBase64(filename, data, comment, false) - if os.IsExist(errors.Unwrap(err)) { - le.Printf("File %v exists. Overwrite [y/n]?", filename) - reader := bufio.NewReader(os.Stdin) - overWriteP, _ := reader.ReadString('\n') - - // Trim space to normalize Windows line endings - overWriteP = strings.TrimSpace(overWriteP) - - if overWriteP == "y" { - err = writeBase64(filename, data, comment, true) - } else { - le.Printf("Aborted\n") - os.Exit(1) - } - } - - if !os.IsExist(errors.Unwrap(err)) && err != nil { - return fmt.Errorf("%w", err) - } - - return nil -} diff --git a/cmd/tkey-sign/main.go b/cmd/tkey-sign/main.go index 20ea906..86a0396 100644 --- a/cmd/tkey-sign/main.go +++ b/cmd/tkey-sign/main.go @@ -18,9 +18,11 @@ import ( "syscall" "github.com/spf13/pflag" + "github.com/tillitis/tkey-sign-cli/signify" "github.com/tillitis/tkeyclient" "github.com/tillitis/tkeysign" "github.com/tillitis/tkeyutil" + "golang.org/x/crypto/blake2s" ) type command int @@ -30,8 +32,26 @@ const ( cmdGetKey cmdSign cmdVerify + cmdDump ) +type devArgs struct { + Path string + Speed int +} + +type USSArgs struct { + FileName string + Request bool + ForceFull bool +} + +// Digest holds the Alg type digest of a message to be signed. +type Digest struct { + Digest string + Alg signify.AlgType +} + // nolint:typecheck // Avoid lint error when the embedding file is missing. // Build copies the built signer here // @@ -48,18 +68,6 @@ var ( verbose = false ) -type pubKey struct { - Alg [2]byte - KeyNum [8]byte - Key [32]byte -} - -type signature struct { - Alg [2]byte - KeyNum [8]byte - Sig [64]byte -} - // May be set to non-empty at build time to indicate that the signer // app has been compiled with touch requirement removed. var signerAppNoTouch string @@ -76,57 +84,46 @@ func GetEmbeddedAppDigest() string { return hex.EncodeToString(digest[:]) } -// signFile uses the connection to the signer and produces an Ed25519 -// signature over the file in fileName. It automatically verifies the +// signMessage uses the connection to the signer and produces an +// Ed25519 signature over message. It automatically verifies the // signature against the provided pubkey. // // It returns the Ed25519 signature on success or an error. -func signFile(signer tkeysign.Signer, pubkey []byte, fileName string) (*signature, error) { - message, err := os.ReadFile(fileName) - if err != nil { - return nil, fmt.Errorf("ReadFile: %w", err) - } - - fileDigest := sha512.Sum512(message) - fileDigestHex := fmt.Sprintf("%x %s\n", fileDigest, fileName) - if verbose { - le.Printf("SHA512 hash: %x", fileDigest) - le.Printf("SHA512 hash: %v", fileDigest) - } - +func signMessage(signer tkeysign.Signer, pubkey signify.PubKey, digest Digest) (*signify.Signature, error) { if signerAppNoTouch != "" { le.Printf("WARNING! This tkey-sign and signer app is built with the touch requirement removed") } - sig, err := signer.Sign([]byte(fileDigestHex)) + sig, err := signer.Sign([]byte(digest.Digest)) if err != nil { return nil, fmt.Errorf("signing failed: %w", err) } + // Create a new signature with the algorithm from the digest + // and the key number from the associated public key. + s, err := signify.NewSignature(digest.Alg, pubkey.KeyNum, sig) + if err != nil { + return nil, fmt.Errorf("couldn't convert to signify signature") + } + if verbose { le.Printf("signature: %x", sig) } - if !ed25519.Verify(pubkey, []byte(fileDigestHex), sig) { + // Check that this actually verifies. + if !ed25519.Verify(pubkey.Key[:], []byte(digest.Digest), sig) { return nil, fmt.Errorf("signature FAILED verification") } - s := signature{ - Alg: [2]byte{'E', 'd'}, - KeyNum: [8]byte{1, 7}, - Sig: [64]byte{}, - } - - copy(s.Sig[:], sig) - return &s, nil } // verifySignature verifies a Ed25519 signature stored in sigFile over // messageFile with public key in pubkeyFile -func verifySignature(messageFile string, sigFile string, pubkeyFile string) error { - signature, err := readSig(sigFile) - if err != nil { +func verifySignature(digest Digest, sigFile string, pubkeyFile string) error { + var signature signify.Signature + + if err := signature.FromFile(sigFile); err != nil { if errors.Is(errors.Unwrap(err), fs.ErrNotExist) { return fmt.Errorf("signature file %v not found, specify with '-x sigfile'", sigFile) } @@ -134,31 +131,22 @@ func verifySignature(messageFile string, sigFile string, pubkeyFile string) erro return fmt.Errorf("%w", err) } - if len(signature.Sig) != 64 { - return fmt.Errorf("invalid length of signature. Expected 64 bytes, got %d bytes", len(signature.Sig)) + if digest.Alg != signature.Alg { + return fmt.Errorf("mismatched algorithm: asked for %s, sig file: %s", digest.Alg, signature.Alg) } - pubkey, err := readKey(pubkeyFile) - if err != nil { - return fmt.Errorf("%w", err) - } + var pubKey signify.PubKey - if len(pubkey.Key) != 32 { - return fmt.Errorf("invalid length of public key. Expected 32 bytes, got %d bytes", len(pubkey.Key)) - } - - message, err := os.ReadFile(messageFile) - if err != nil { - return fmt.Errorf("could not read %s: %w", messageFile, err) + if err := pubKey.FromFile(pubkeyFile); err != nil { + return fmt.Errorf("%w", err) } - digest := sha512.Sum512(message) - digestHex := fmt.Sprintf("%x %s\n", digest, messageFile) - if verbose { - le.Printf("SHA512 hash: %x", digest) + // Check if this is really the expected the public pubkeyFile + if pubKey.KeyNum != signature.KeyNum { + return fmt.Errorf("invalid public key") } - if !ed25519.Verify(pubkey.Key[:], []byte(digestHex), signature.Sig[:]) { + if !ed25519.Verify(pubKey.Key[:], []byte(digest.Digest), signature.Sig[:]) { return fmt.Errorf("signature not valid") } @@ -172,11 +160,19 @@ func verifySignature(messageFile string, sigFile string, pubkeyFile string) erro // // It then connects to the running signer and returns an interface to // the Signer, the public key, and a possible error. -func loadSigner(devPath string, speed int, fileUSS string, enterUSS bool, forceFullUss bool) (*tkeysign.Signer, []byte, error) { +func loadSigner(devPath string, speed int, uss USSArgs) (*tkeysign.Signer, []byte, error) { if !verbose { tkeyclient.SilenceLogging() } + if uss.Request && uss.FileName != "" { + return nil, nil, errors.New("pass only one of --uss or --uss-file") + } + + if uss.ForceFull && uss.FileName == "" != uss.Request { + return nil, nil, errors.New("--force-full-uss unusable unless you also specify --uss or --uss-file") + } + if devPath == "" { var err error devPath, err = tkeyclient.DetectSerialPort(false) @@ -196,7 +192,7 @@ func loadSigner(devPath string, speed int, fileUSS string, enterUSS bool, forceF options = append(options, tkeyclient.WithSpeed(speed)) } - if forceFullUss { + if uss.ForceFull { options = append(options, tkeyclient.WithFullUss()) } @@ -208,15 +204,15 @@ func loadSigner(devPath string, speed int, fileUSS string, enterUSS bool, forceF var secret []byte var err error - if enterUSS { + if uss.Request { secret, err = tkeyutil.InputUSS() if err != nil { tk.Close() return nil, nil, fmt.Errorf("InputUSS: %w", err) } } - if fileUSS != "" { - secret, err = tkeyutil.ReadUSS(fileUSS) + if uss.FileName != "" { + secret, err = tkeyutil.ReadUSS(uss.FileName) if err != nil { tk.Close() return nil, nil, fmt.Errorf("ReadUSS: %w", err) @@ -232,7 +228,7 @@ func loadSigner(devPath string, speed int, fileUSS string, enterUSS bool, forceF le.Printf("Signer app loaded.") } } else { - if enterUSS || fileUSS != "" { + if uss.Request || uss.FileName != "" { le.Printf("WARNING: App already loaded, your USS won't be used.") } else { le.Printf("WARNING: App already loaded.") @@ -257,16 +253,180 @@ func loadSigner(devPath string, speed int, fileUSS string, enterUSS bool, forceF return &signer, pubkey, nil } +func dumpFiles(sigFn string, keyFn string) error { + var sig signify.Signature + var key signify.PubKey + + if err := sig.FromFile(sigFn); err != nil { + return fmt.Errorf("%w", err) + } + + fmt.Printf("Signature\n Alg: %s\n Num: %x\n Sig: %x\n", sig.Alg, sig.KeyNum, sig.Sig) + + if err := key.FromFile(keyFn); err != nil { + return fmt.Errorf("%w", err) + } + + fmt.Printf("Key\n Alg: %s\n Num: %x\n Key: %x\n", key.Alg, key.KeyNum, key.Key) + + return nil +} + +func GetKey(keyFn string, overwrite bool, dev devArgs, uss USSArgs) error { + if keyFn == "" { + return errors.New("please provide -p pubkey") + } + + signer, pub, err := loadSigner(dev.Path, dev.Speed, uss) + if err != nil { + return fmt.Errorf("%w", err) + } + + defer signer.Close() + pubkey, err := signify.NewPubKey(pub) + if err != nil { + le.Printf("Couldn't convert public key from signer to Signify key\n") + } + + comment := "tkey public key" + if overwrite { + err = pubkey.ToFile(keyFn, comment, true) + } else { + err = writeRetry(keyFn, &pubkey, comment) + } + + if err != nil { + return fmt.Errorf("%w", err) + } + + return nil +} + +func Sign(digest Digest, keyFn string, sigFn string, overwrite bool, dev devArgs, uss USSArgs) error { + var pubKey signify.PubKey + + if keyFn == "" { + return errors.New("provide -p pubkey") + } + + if err := pubKey.FromFile(keyFn); err != nil { + return fmt.Errorf("couldn't read pubkey file: %w", err) + } + + signer, pub, err := loadSigner(dev.Path, dev.Speed, uss) + if err != nil { + return fmt.Errorf("couldn't load signer: %w", err) + } + + defer signer.Close() + + // Make sure the returned pubkey from the TKey is the one the + // user expected. + if !bytes.Equal(pub, pubKey.Key[:]) { + return fmt.Errorf("key from file %v not equal to loaded app's", keyFn) + } + + sig, err := signMessage(*signer, pubKey, digest) + if err != nil { + return fmt.Errorf("signing failed: %w", err) + } + + comment := fmt.Sprintf("verify with %v", keyFn) + if overwrite { + err = sig.ToFile(sigFn, comment, true) + } else { + err = writeRetry(sigFn, sig, comment) + } + + if err != nil { + return fmt.Errorf("couldn't store signature: %w", err) + } + + return nil +} + +func Verify(digest Digest, keyFn string, sigFn string) error { + if keyFn == "" { + return errors.New("provide public key file path with -p pubkey") + } + + if err := verifySignature(digest, sigFn, keyFn); err != nil { + return fmt.Errorf("verifying failed: %w", err) + } + + return nil +} + +func Dump(keyFn string, sigFn string) error { + if keyFn == "" { + return errors.New("provide public key file path with -p pubkey") + } + + err := dumpFiles(sigFn, keyFn) + if err != nil { + return fmt.Errorf("error dumping data: %w", err) + } + + return nil +} + +// getDigest returns the digest to sign or verify. +func getDigest(msgFn string, alg string) (Digest, error) { + var digest Digest + + if msgFn == "" { + return digest, errors.New("provide -m messagefile") + } + + file, err := os.ReadFile(msgFn) + if err != nil { + return digest, fmt.Errorf("%w", err) + } + + switch alg { + case "ed": + // default + + digest.Alg = signify.Ed + fileDigest := sha512.Sum512(file) + // The actual message is compatible with output from + // sha512sum, including the filename, to work with + // scripts using signify. + // + // XXX Do we really want to include the filename + // here? Keeping it for backwards compatibility now. + digest.Digest = fmt.Sprintf("%x %s\n", fileDigest, msgFn) + if verbose { + le.Printf("SHA512 digest: %x", fileDigest) + } + + case "b2s": + digest.Alg = signify.B2sEd + d := blake2s.Sum256(file) + digest.Digest = string(d[:]) + + default: + return digest, errors.New("unknown algorithm") + } + + if verbose { + le.Printf("message to be signed: %v", digest.Digest) + } + + return digest, nil +} + func usage() { desc := fmt.Sprintf(`Usage: %[1]s -h/--help +%[1]s -D/--dump -p/--public pubkey -m message [-x sigfile] %[1]s -G/--getkey -p/--public pubkey [-d/--port device] [-f/--force] [-s/--speed speed] [--uss] [--uss-file ussfile] [--verbose] -%[1]s -S/--sign -m message -p/--public pubkey [-d/--port device] [-f/--force] [-s speed] [--uss] [--uss-file ussfile] [--verbose] [-x sigfile] +%[1]s -S/--sign -m message -p/--public pubkey [-a/--alg algorithm] [-d/--port device] [-f/--force] [-s speed] [--uss] [--uss-file ussfile] [--verbose] [-x sigfile] -%[1]s -V/--verify -m message -p/--public pubkey [-x sigfile] +%[1]s -V/--verify -m message -p/--public pubkey [-a/--alg algorithm] [-x sigfile] %[1]s --version @@ -278,8 +438,10 @@ algorithm is Ed25519. Exit status code is 0 if everything went well or non-zero if unsuccessful. Alternatively, -G/--getkey can be used to receive the public key of -the signer app on the TKey. Specify where to store it with -p key.pub`, - os.Args[0]) +the signer app on the TKey. Specify where to store it with -p key.pub. + +Use -D/--dump to get more information about signature and public key +files.`, os.Args[0]) le.Printf("%s\n\n%s", desc, pflag.CommandLine.FlagUsagesWrapped(86)) @@ -299,6 +461,8 @@ https://github.com/tillitis/tkey-sign-cli/ func main() { var cmd command var cmdArgs int + alg := pflag.StringP("alg", "a", "ed", "Hash message file before ops (ed: SHA-512 output, b2s: BLAKE2s") + dump := pflag.BoolP("dump", "D", false, "Dump data about files.") getKey := pflag.BoolP("getkey", "G", false, "Get public key.") sign := pflag.BoolP("sign", "S", false, "Sign the message.") verify := pflag.BoolP("verify", "V", false, "Verify signature of the message.") @@ -345,6 +509,11 @@ func main() { } + if *dump { + cmd = cmdDump + cmdArgs++ + } + if *getKey { cmd = cmdGetKey cmdArgs++ @@ -365,137 +534,59 @@ func main() { os.Exit(1) } - switch cmd { - case cmdGetKey: - if *keyFile == "" { - le.Printf("Provide public key file with -p pubkey") - os.Exit(1) - } - - if *enterUss && *ussFile != "" { - le.Printf("Pass only one of --uss or --uss-file.\n\n") - os.Exit(1) - } - - if *forceFullUss && *ussFile == "" != *enterUss { - le.Printf("--force-full-uss unusable unless you also specify --uss or --uss-file.\n\n") - os.Exit(1) - } - - signer, pub, err := loadSigner(*devPath, *speed, *ussFile, *enterUss, *forceFullUss) - if err != nil { - le.Printf("Couldn't load signer: %v", err) - os.Exit(1) - } - - pubkey := pubKey{ - Alg: [2]byte{'E', 'd'}, - KeyNum: [8]byte{1, 7}, - Key: [32]byte{}, - } - - copy(pubkey.Key[:], pub) - - comment := "tkey public key" - if *force { - err = writeBase64(*keyFile, pubkey, comment, true) - } else { - err = writeRetry(*keyFile, pubkey, comment) - } - - if err != nil { - le.Printf("%v", err) - signer.Close() - os.Exit(1) - } - - signer.Close() - - case cmdSign: - if *messageFile == "" { - le.Printf("Provide message file with -m message") - os.Exit(1) - } - - if *keyFile == "" { - le.Printf("Provide public key file with -p pubkey") - os.Exit(1) - } - - if *sigFile == "" { - *sigFile = *messageFile + ".sig" - } + dev := devArgs{ + Path: *devPath, + Speed: *speed, + } - pubkey, err := readKey(*keyFile) - if err != nil { - le.Printf("Couldn't read pubkey file: %v", err) - os.Exit(1) - } + uss := USSArgs{ + FileName: *ussFile, + Request: *enterUss, + ForceFull: *forceFullUss, + } - if *enterUss && *ussFile != "" { - le.Printf("Pass only one of --uss or --uss-file.\n\n") - os.Exit(1) - } + var digest Digest - if *forceFullUss && *ussFile == "" != *enterUss { - le.Printf("--force-full-uss unusable unless you also specify --uss or --uss-file.\n\n") - os.Exit(1) - } + if cmd == cmdSign || cmd == cmdVerify { + var err error - signer, pub, err := loadSigner(*devPath, *speed, *ussFile, *enterUss, *forceFullUss) + digest, err = getDigest(*messageFile, *alg) if err != nil { - le.Printf("Couldn't load signer: %v", err) + fmt.Printf("%v\n", err) os.Exit(1) } + } - if !bytes.Equal(pub, pubkey.Key[:]) { - le.Printf("Public key from file %v not equal to loaded app's", *keyFile) - os.Exit(1) - } + if *sigFile == "" { + *sigFile = *messageFile + ".sig" + } - sig, err := signFile(*signer, pub, *messageFile) - if err != nil { - le.Printf("signing failed: %v", err) - signer.Close() + switch cmd { + case cmdGetKey: + if err := GetKey(*keyFile, *force, dev, uss); err != nil { + fmt.Printf("%v\n", err) os.Exit(1) } - comment := fmt.Sprintf("verify with %v", *keyFile) - if *force { - err = writeBase64(*sigFile, sig, comment, true) - } else { - err = writeRetry(*sigFile, sig, comment) - } - - if err != nil { - le.Printf("Couldn't store signature: %v", err) - signer.Close() + case cmdSign: + if err := Sign(digest, *keyFile, *sigFile, *force, dev, uss); err != nil { + fmt.Printf("%v\n", err) os.Exit(1) } - signer.Close() - case cmdVerify: - if *messageFile == "" { - le.Printf("Provide message file with -m message") + if err := Verify(digest, *keyFile, *sigFile); err != nil { + fmt.Printf("%v\n", err) os.Exit(1) } - if *keyFile == "" { - le.Printf("Provide public key file path with -p pubkey") - os.Exit(1) - } - - if *sigFile == "" { - *sigFile = *messageFile + ".sig" - } + le.Printf("Signature verified") - err := verifySignature(*messageFile, *sigFile, *keyFile) - if err != nil { - le.Printf("Error verifying: %v", err) + case cmdDump: + if err := Dump(*keyFile, *sigFile); err != nil { + fmt.Printf("%v\n", err) os.Exit(1) } - le.Printf("Signature verified") default: pflag.Usage() diff --git a/cmd/tkey-sign/util.go b/cmd/tkey-sign/util.go index 2ac8cc4..261cf30 100644 --- a/cmd/tkey-sign/util.go +++ b/cmd/tkey-sign/util.go @@ -4,6 +4,7 @@ package main import ( + "bufio" "errors" "fmt" "io" @@ -12,6 +13,7 @@ import ( "runtime/debug" "strings" + "github.com/tillitis/tkey-sign-cli/signify" "github.com/tillitis/tkeyclient" "github.com/tillitis/tkeysign" ) @@ -74,3 +76,31 @@ func readBuildInfo() string { } return v } + +// writeRetry writes the data in the file given in filename as base64. +// If a file already exists it prompts interactively for permission to +// overwrite the file. +func writeRetry(filename string, data signify.Data, comment string) error { + err := data.ToFile(filename, comment, false) + if os.IsExist(errors.Unwrap(err)) { + le.Printf("File %v exists. Overwrite [y/n]?", filename) + reader := bufio.NewReader(os.Stdin) + overWriteP, _ := reader.ReadString('\n') + + // Trim space to normalize Windows line endings + overWriteP = strings.TrimSpace(overWriteP) + + if overWriteP == "y" { + err = data.ToFile(filename, comment, true) + } else { + le.Printf("Aborted\n") + os.Exit(1) + } + } + + if !os.IsExist(errors.Unwrap(err)) && err != nil { + return fmt.Errorf("%w", err) + } + + return nil +} diff --git a/doc/tkey-sign.1 b/doc/tkey-sign.1 index 6168f89..2f55c34 100644 --- a/doc/tkey-sign.1 +++ b/doc/tkey-sign.1 @@ -5,7 +5,7 @@ .nh .ad l .\" Begin generated content: -.TH "tkey-sign" "1" "2026-03-23" +.TH "tkey-sign" "1" "2026-04-14" .PP .SH NAME .PP @@ -15,11 +15,13 @@ tkey-sign - Create or verify digital signatures with the Tillitis TKey .PP \fBtkey-sign\fR -h/--help .PP +\fBtkey-sign\fR -D/--dump -p/--public pubkey -m message [-x sigfile] +.PP \fBtkey-sign\fR -G/--getkey -p/--public pubkey [-d/--port device] [-f/--force] [-s/--speed speed] [--uss] [--uss-file ussfile] [--force-full-uss] [--verbose] .PP -\fBtkey-sign\fR -S/--sign -m message -p/--public pubkey [-d/--port device] [-f/--force] [-s speed] [--uss] [--uss-file ussfile] [--force-full-uss] [--verbose] [-x sigfile] +\fBtkey-sign\fR -S/--sign -m message -p/--public pubkey [-a/--alg algorithm] [-d/--port device] [-f/--force] [-s speed] [--uss] [--uss-file ussfile] [--force-full-uss] [--verbose] [-x sigfile] .PP -\fBtkey-sign\fR -V/--verify -m message -p/--public pubkey [--verbose] [-x sigfile] +\fBtkey-sign\fR -V/--verify -m message -p/--public [-a/--alg algorithm] pubkey [--verbose] [-x sigfile] .PP \fBtkey-sign\fR --version .PP @@ -30,6 +32,13 @@ files.\& The signature is created by the \fBsigner\fR device app running on the Tillitis TKey.\& The signer is automatically loaded into the TKey when signing or extracting the public key.\& .PP +\fB-D, --dump\fR +.PP +.RS 4 +Print helpful information about the contents of decoded signature and +public key files.\& +.PP +.RE \fB-G, --getkey\fR .PP .RS 4 @@ -59,6 +68,14 @@ Needs \fB-m message\fR and \fB-p pubkey\fR.\& Default signature file is message. Specify with \fB-x sigfile\fR.\& .PP .RE +\fB-a, --alg algorithm\fR +.PP +.RS 4 +Specify the hash algorithm to use on the message file before +signing or verifying.\& Options are "ed" (SHA-512) and "b2s" +(BLAKE2s).\& Default is "ed" +.PP +.RE \fB-d, --port device\fR .PP .RS 4 @@ -152,14 +169,15 @@ with "untrusted comment: ", then encoded in BASE64: .PP .PD 0 .IP \(bu 4 -2 byte algorithm identifier, so far only "Ed" for Ed25519.\& +2 byte algorithm identifier, "Ed" for Ed25519 hashed with SHA-512, +and "Eb", Ed25519 hashed with BLAKE2s.\& .IP \(bu 4 8 byte key number (unused by tkey-sign).\& .IP \(bu 4 64 byte Ed25519 signature.\& .PD .PP -Pubkey files are expected to contain, a one-line comment beginning +Pubkey files are expected to contain a one-line comment beginning with "untrusted comment: ", then encoded in BASE64: .PP .PD 0 @@ -171,6 +189,19 @@ with "untrusted comment: ", then encoded in BASE64: 32 byte Ed25519 public key.\& .PD .PP +NOTE WELL: The original `signify` command does Ed25519 signing on the +complete file content, not a digest.\& To be compatible we still use the +"Ed" identifier in our signature files, but you need to do: +.PP +.nf +.RS 4 +sha512 file > digest +signify -V -m digest -x file\&.sig -p key\&.pub +.fi +.RE +.PP +to actually verify the right message.\& See the signify-verify script.\& +.PP .SH EXIT STATUS .PP The exit code is 0 on success and >0 if an error occurs.\& diff --git a/doc/tkey-sign.scd b/doc/tkey-sign.scd index 0d83f1d..4509141 100644 --- a/doc/tkey-sign.scd +++ b/doc/tkey-sign.scd @@ -8,11 +8,13 @@ tkey-sign - Create or verify digital signatures with the Tillitis TKey *tkey-sign* -h/--help +*tkey-sign* -D/--dump -p/--public pubkey -m message [-x sigfile] + *tkey-sign* -G/--getkey -p/--public pubkey [-d/--port device] [-f/--force] [-s/--speed speed] [--uss] [--uss-file ussfile] [--force-full-uss] [--verbose] -*tkey-sign* -S/--sign -m message -p/--public pubkey [-d/--port device] [-f/--force] [-s speed] [--uss] [--uss-file ussfile] [--force-full-uss] [--verbose] [-x sigfile] +*tkey-sign* -S/--sign -m message -p/--public pubkey [-a/--alg algorithm] [-d/--port device] [-f/--force] [-s speed] [--uss] [--uss-file ussfile] [--force-full-uss] [--verbose] [-x sigfile] -*tkey-sign* -V/--verify -m message -p/--public pubkey [--verbose] [-x sigfile] +*tkey-sign* -V/--verify -m message -p/--public [-a/--alg algorithm] pubkey [--verbose] [-x sigfile] *tkey-sign* --version @@ -23,6 +25,11 @@ files. The signature is created by the *signer* device app running on the Tillitis TKey. The signer is automatically loaded into the TKey when signing or extracting the public key. +*-D, --dump* + + Print helpful information about the contents of decoded signature and + public key files. + *-G, --getkey* Load device app (if not already loaded) and output its public key into @@ -46,6 +53,12 @@ when signing or extracting the public key. Needs *-m message* and *-p pubkey*. Default signature file is message.sig. Specify with *-x sigfile*. +*-a, --alg algorithm* + + Specify the hash algorithm to use on the message file before + signing or verifying. Options are "ed" (SHA-512) and "b2s" + (BLAKE2s). Default is "ed" + *-d, --port device* Specify the device path for the TKey, typically something like @@ -113,17 +126,29 @@ when signing or extracting the public key. Signature files are expected to contain a one-line comment beginning with "untrusted comment: ", then encoded in BASE64: -- 2 byte algorithm identifier, so far only "Ed" for Ed25519. +- 2 byte algorithm identifier, "Ed" for Ed25519 hashed with SHA-512, + and "Eb", Ed25519 hashed with BLAKE2s. - 8 byte key number (unused by tkey-sign). - 64 byte Ed25519 signature. -Pubkey files are expected to contain, a one-line comment beginning +Pubkey files are expected to contain a one-line comment beginning with "untrusted comment: ", then encoded in BASE64: - 2 byte algorithm identifier, so far only "Ed" for Ed25519. - 8 byte key number (unused by tkey-sign). - 32 byte Ed25519 public key. +NOTE WELL: The original `signify` command does Ed25519 signing on the +complete file content, not a digest. To be compatible we still use the +"Ed" identifier in our signature files, but you need to do: + +``` +sha512 file > digest +signify -V -m digest -x file.sig -p key.pub +``` + +to actually verify the right message. See the signify-verify script. + # EXIT STATUS The exit code is 0 on success and >0 if an error occurs. diff --git a/signify/signify.go b/signify/signify.go new file mode 100644 index 0000000..6f4fc1b --- /dev/null +++ b/signify/signify.go @@ -0,0 +1,298 @@ +// SPDX-FileCopyrightText: 2026 Tillitis AB +// SPDX-License-Identifier: BSD-2-Clause + +// Package signify implements types and methods to interact with data +// compatible with the files the signify command use. +// +// NB! We only support Ed25519 signatures and public keys. +package signify + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "fmt" + "os" + "strings" +) + +// The PubKey and Signature types adher to this interface. +// +// FromFile parses a public key or signature from the Signify file +// format. +// +// ToFile exports a public key or signature to the Signify file format. +// +// FromBuffer parses from a Signify buffer. +// +// ToBuffer export to a Signify buffer. +type Data interface { + FromFile(fileName string) error + ToFile(fileName string, comment string, overwrite bool) error + FromBuffer(buf []byte) error + ToBuffer(comment string) ([]byte, error) +} + +type AlgType int + +const ( + Ed AlgType = iota + B2sEd +) + +func (a AlgType) String() string { + switch a { + case Ed: + return "Ed" + + case B2sEd: + return "B2sEd" + + default: + return "unknown" + } +} + +// A signify-compatible Ed25519 public key. Instantiate directly or +// using NewPubKey if you have a slice. +type PubKey struct { + Alg [2]byte + KeyNum [8]byte + Key [ed25519.PublicKeySize]byte +} + +// A signify-compatible Ed25519 signature. Instantiate directly or +// using NewSignature if you have a slice. +type Signature struct { + Alg AlgType + KeyNum [8]byte + Sig [ed25519.SignatureSize]byte +} + +type signifySignature struct { + Alg [2]byte + KeyNum [8]byte + Sig [ed25519.SignatureSize]byte +} + +// NewPubKey instantiates a signify public key (PubKey) from a byte +// slice. +// +// srcKey is expected to be an Ed25519 public key of exactly 32 bytes. +// +// It will randomize a KeyNum identifier to associate a future +// signature with this key. If you want to do this yourself, +// instantiate the PubKey directly. +func NewPubKey(srcKey []byte) (PubKey, error) { + var key PubKey + + if len(srcKey) != ed25519.PublicKeySize { + return key, fmt.Errorf("key too large") + } + + key.Alg = [2]byte{'E', 'd'} + + // Randomize a keynumber so any signatures generated from this + // one can be associated with this public key. + if _, err := rand.Read(key.KeyNum[:]); err != nil { + return key, fmt.Errorf("%w", err) + } + + copy(key.Key[:], srcKey) + + return key, nil +} + +func (p *PubKey) FromFile(fileName string) error { + input, err := os.ReadFile(fileName) + if err != nil { + return fmt.Errorf("%w", err) + } + + return p.FromBuffer(input) +} + +func (p *PubKey) FromBuffer(b []byte) error { + buf, err := fromSlice(b) + if err != nil { + return fmt.Errorf("could not decode: %w", err) + } + + r := bytes.NewReader(buf) + if err := binary.Read(r, binary.BigEndian, p); err != nil { + return fmt.Errorf("%w", err) + } + + if p.Alg != [2]byte{'E', 'd'} { + return fmt.Errorf("incompatible key") + } + + return nil +} + +func (p *PubKey) ToBuffer(comment string) ([]byte, error) { + return toSlice(p, comment) +} + +func (p *PubKey) ToFile(fileName string, comment string, overwrite bool) error { + buf, err := p.ToBuffer(comment) + if err != nil { + return err + } + + return writeFile(fileName, buf, overwrite) +} + +// NewSignature instantiates a signify Signature from a byte slice. +// +// It expects an algorithm type, an identifier corresponding to an +// existing public key, and an Ed25519 signature slice. +// +// It returns a Signature and any error. +func NewSignature(alg AlgType, keyNum [8]byte, srcSig []byte) (Signature, error) { + var sig Signature + + if alg != Ed && alg != B2sEd { + return sig, fmt.Errorf("unknown algorithm") + } + + if len(srcSig) != ed25519.SignatureSize { + return sig, fmt.Errorf("signature too large") + } + + sig.Alg = alg + sig.KeyNum = keyNum + copy(sig.Sig[:], srcSig) + + return sig, nil +} + +func (s *Signature) FromFile(fileName string) error { + input, err := os.ReadFile(fileName) + if err != nil { + return fmt.Errorf("%w", err) + } + + return s.FromBuffer(input) +} + +func (s *Signature) FromBuffer(b []byte) error { + var sig signifySignature + + buf, err := fromSlice(b) + if err != nil { + return fmt.Errorf("could not decode: %w", err) + } + + r := bytes.NewReader(buf) + if err := binary.Read(r, binary.BigEndian, &sig); err != nil { + return fmt.Errorf("binary read: %w", err) + } + + switch sig.Alg { + case [2]byte{'E', 'd'}: + s.Alg = Ed + + case [2]byte{'E', 'b'}: + s.Alg = B2sEd + + default: + return fmt.Errorf("unknown signature algorithm") + } + + s.KeyNum = sig.KeyNum + s.Sig = sig.Sig + + return nil +} + +func (s *Signature) ToBuffer(comment string) ([]byte, error) { + signifySig := signifySignature{ + KeyNum: s.KeyNum, + Sig: s.Sig, + } + + switch s.Alg { + case Ed: + signifySig.Alg = [2]uint8{'E', 'd'} + + case B2sEd: + signifySig.Alg = [2]uint8{'E', 'b'} + + default: + return nil, fmt.Errorf("unknown sig algorithm") + + } + + return toSlice(signifySig, comment) +} + +func (s *Signature) ToFile(fileName string, comment string, overwrite bool) error { + buf, err := s.ToBuffer(comment) + if err != nil { + return err + } + + return writeFile(fileName, buf, overwrite) +} + +func fromSlice(input []byte) ([]byte, error) { + lines := strings.Split(string(input), "\n") + if len(lines) < 2 { + return nil, fmt.Errorf("too few lines") + } + + data, err := base64.StdEncoding.DecodeString(lines[1]) + if err != nil { + return nil, fmt.Errorf("could not decode: %w", err) + } + + return data, nil +} + +func toSlice(data any, comment string) ([]byte, error) { + var binBuf bytes.Buffer + + err := binary.Write(&binBuf, binary.BigEndian, data) + if err != nil { + return []byte{}, fmt.Errorf("%w", err) + } + + b64 := base64.StdEncoding.EncodeToString(binBuf.Bytes()) + b64 += "\n" + + var buf bytes.Buffer + if _, err := buf.WriteString(fmt.Sprintf("untrusted comment: %s\n%s", comment, b64)); err != nil { + return []byte{}, fmt.Errorf("%w", err) + } + + return buf.Bytes(), nil +} + +// writeFile writes buf into file filename. If overwrite is true it +// overwrites any existing file, otherwise it returns an error. +func writeFile(filename string, buf []byte, overwrite bool) error { + var f *os.File + + f, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o666) + if err != nil { + if os.IsExist(err) && overwrite { + f, err = os.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0o666) + if err != nil { + return fmt.Errorf("%w", err) + } + } else { + return fmt.Errorf("%w", err) + } + } + + defer f.Close() + + if _, err := f.Write(buf); err != nil { + return fmt.Errorf("%w", err) + } + + return nil +} diff --git a/signify/signify_test.go b/signify/signify_test.go new file mode 100644 index 0000000..fcd1cc0 --- /dev/null +++ b/signify/signify_test.go @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: 2026 Tillitis AB +// SPDX-License-Identifier: BSD-2-Clause + +package signify + +import ( + "crypto/ed25519" + "fmt" + "testing" +) + +// Full signature file +const WorkingSigString = `untrusted comment: app_a.bin +RWQBBwAAAAAAALE/DLVQ8RU5OA11qzhxDZ5nDOgbVGhxNwWlylI2YdPHIBVH/Q+HnhWfO5CxgPUb6EOCxG8ZzPVy+lQt4atUHAE= +` + +// Fully parsed +var WorkingSig = Signature{ + Alg: Ed, + KeyNum: [8]byte{0x1, 0x7}, + Sig: [ed25519.SignatureSize]byte{0xb1, 0x3f, 0xc, 0xb5, 0x50, 0xf1, 0x15, 0x39, 0x38, 0xd, 0x75, 0xab, 0x38, 0x71, 0xd, 0x9e, 0x67, 0xc, 0xe8, 0x1b, 0x54, 0x68, 0x71, 0x37, 0x5, 0xa5, 0xca, 0x52, 0x36, 0x61, 0xd3, 0xc7, 0x20, 0x15, 0x47, 0xfd, 0xf, 0x87, 0x9e, 0x15, 0x9f, 0x3b, 0x90, 0xb1, 0x80, 0xf5, 0x1b, 0xe8, 0x43, 0x82, 0xc4, 0x6f, 0x19, 0xcc, 0xf5, 0x72, 0xfa, 0x54, 0x2d, 0xe1, 0xab, 0x54, 0x1c, 0x1}, +} + +// Full public key file +const WorkingKeyString = `untrusted comment: +RWQAAAAAAAAAAJtidzMj70GhGDSCQZTlUWTTJeuc3MEN3afRCt5PvY9t +` + +var WorkingKey = PubKey{ + Alg: [2]byte{'E', 'd'}, + KeyNum: [8]byte{}, + Key: [32]byte{0x9b, 0x62, 0x77, 0x33, 0x23, 0xef, 0x41, 0xa1, 0x18, 0x34, 0x82, 0x41, 0x94, 0xe5, 0x51, 0x64, 0xd3, 0x25, 0xeb, 0x9c, 0xdc, 0xc1, 0xd, 0xdd, 0xa7, 0xd1, 0xa, 0xde, 0x4f, 0xbd, 0x8f, 0x6d}, +} + +// TestSig generates a Signify format buffer, then parses the same +// buffer back into a signature. +func TestSig(t *testing.T) { + var s Signature + + t.Parallel() + buf, err := WorkingSig.ToBuffer("") + if err != nil { + t.Fatalf("Couldn't generate signify format: %v", err) + } + + if err := s.FromBuffer(buf); err != nil { + t.Fatalf("Couldn't read or parse signify format: %v", err) + } + + if s != WorkingSig { + fmt.Printf("s: %#v\n", s) + fmt.Printf("WorkingSig: %#v\n", WorkingSig) + t.Fatal("generating and parsing back signature was different") + } + +} + +func TestParseSig(t *testing.T) { + var sig Signature + + t.Parallel() + if err := sig.FromBuffer([]byte(WorkingSigString)); err != nil { + t.Fatalf("%v\n", err) + } + + if sig != WorkingSig { + t.Fatal("Parsed signature not correct\n") + } +} + +// TestKey generates a signify format buffer for a public key, then +// parses the same buffer back into a key. +func TestKey(t *testing.T) { + var p PubKey + + t.Parallel() + buf, err := WorkingKey.ToBuffer("") + if err != nil { + t.Fatalf("Couldn't generate signify format: %v", err) + } + + if err := p.FromBuffer(buf); err != nil { + t.Fatalf("Couldn't read or parse signify format: %v", err) + } + + if p != WorkingKey { + t.Fatal("generating and parsing back key was different") + } + +} + +func TestParsePubKey(t *testing.T) { + var key PubKey + + t.Parallel() + if err := key.FromBuffer([]byte(WorkingKeyString)); err != nil { + t.Fatalf("%v\n", err) + } + + if key != WorkingKey { + t.Fatal("Parsed public key not correct\n") + } + +} + +func TestGenSigB64(t *testing.T) { + t.Parallel() + + b64, err := WorkingSig.ToBuffer("app_a.bin") + if err != nil { + t.Fatalf("error: %v", err) + } + + if string(b64) != WorkingSigString { + fmt.Printf("Expected (%v bytes): %v\n", len(WorkingSigString), WorkingSigString) + fmt.Printf("Got (%v bytes): %v\n", len(string(b64)), string(b64)) + + t.Fatal("Known good sig generates no good string") + } +} + +func TestGenPubKeyB64(t *testing.T) { + t.Parallel() + + b64, err := WorkingKey.ToBuffer("") + if err != nil { + t.Fatalf("error: %v", err) + } + + if string(b64) != WorkingKeyString { + fmt.Printf("Expected (%v bytes): %v\n", len(WorkingKeyString), WorkingKeyString) + fmt.Printf("Got (%v bytes): %v\n", len(string(b64)), string(b64)) + + t.Fatal("Known good key generates no good string") + } +} + +func TestSigFile(t *testing.T) { + t.Parallel() + + var s Signature + + if err := WorkingSig.ToFile("test.sig", "a comment", true); err != nil { + t.Fatalf("Couldn't generate or write file test.sig: %v", err) + } + + if err := s.FromFile("test.sig"); err != nil { + t.Fatalf("Couldn't read or parse test.sig: %v", err) + } + + if s != WorkingSig { + t.Fatal("Signature read from file not same as written") + } +} + +func TestPubFile(t *testing.T) { + t.Parallel() + + var p PubKey + + if err := WorkingKey.ToFile("test.pub", "a comment", true); err != nil { + t.Fatalf("Couldn't generate or write file test.pub: %v", err) + } + + if err := p.FromFile("test.pub"); err != nil { + t.Fatalf("Couldn't read or parse test.pub: %v", err) + } + + if p != WorkingKey { + t.Fatal("Key read from file not same as written") + } +}