Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Major rewrite with cunicu.li/go-iso7816 #14

Merged
merged 2 commits into from
Nov 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ linters-settings:
sections:
- standard
- default
- prefix(cunicu.li/cunicu)
- prefix(cunicu.li/go-ykoath)
- blank
- dot

Expand Down
2 changes: 1 addition & 1 deletion .reuse/dep5
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ Upstream-Name: go-ykoath
Upstream-Contact: Steffen Vogel <[email protected]>
Source: https://cunicu.li

Files: go.sum CHANGELOG.md README.md
Files: go.sum CHANGELOG.md README.md mockdata/**
Copyright: 2018 Joern Barthel <[email protected]>
License: Apache-2.0
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,24 @@ The package `ykoath` implements the YubiKey [YKOATH protocol](https://developers
## Usage

```go
oath, err := ykoath.New()
c, err := ykoath.New()
if err != nil {
log.Fatal(err)
}

defer oath.Close()
defer c.Close()

if _, err = oath.Select(); err != nil {
if _, err = c.Select(); err != nil {
log.Fatalf("Failed to select app: %v", err)
}

names, err := oath.List()
names, err := c.List()
if err != nil {
log.Fatal("Failed to list slots: %v", err)
}

for _, name := range names {
calc, err := oath.Calculate(name.Name, func(name string) error {
calc, err := c.Calculate(name.Name, func(name string) error {
log.Printf("*** Please touch your YubiKey to unlock slot: %q ***", name)
return nil
})
Expand All @@ -47,11 +47,11 @@ for _, name := range names {
log.Printf("Got one-time-password %s for slot %q", calc, name)
}

if err := oath.Put("test", ykoath.HmacSha1, ykoath.Totp, 6, []byte("open sesame"), true); err != nil {
if err := c.Put("test", ykoath.HmacSha1, ykoath.Totp, 6, []byte("open sesame"), true); err != nil {
log.Fatal(err)
}

if err := oath.Put("test2", ykoath.HmacSha1, ykoath.Totp, 6, []byte("open sesame"), true); err != nil {
if err := c.Put("test2", ykoath.HmacSha1, ykoath.Totp, 6, []byte("open sesame"), true); err != nil {
log.Fatal(err)
}
```
Expand Down
146 changes: 90 additions & 56 deletions calculate.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2018 Joern Barthel <joern.barthel@kreuzwerker.de>
// SPDX-FileCopyrightText: 2023 Steffen Vogel <post@steffenvogel.de>
// SPDX-License-Identifier: Apache-2.0

package ykoath
Expand All @@ -8,103 +8,132 @@ import (
"errors"
"fmt"
"strings"

"cunicu.li/go-iso7816/encoding/tlv"
)

var (
errNoValuesFound = errors.New("no values found in response")
errUnknownName = errors.New("no such name configured")
errMultipleMatches = errors.New("multiple matches found")
errTouchRequired = errors.New("touch-required")
ErrNoValuesFound = errors.New("no values found in response")
ErrUnknownName = errors.New("no such name configured")
ErrMultipleMatches = errors.New("multiple matches found")
ErrTouchRequired = errors.New("touch required")
ErrTouchCallbackRequired = errors.New("touch callback required")
ErrChallengeRequired = errors.New("challenge required")
)

// Calculate is a high-level function that first identifies all TOTP credentials
// that are configured and returns the matching one (if no touch is required) or
// fires the callback and then fetches the name again while blocking during
// the device awaiting touch
func (o *OATH) Calculate(name string, touchRequiredCallback func(string) error) (string, error) {
res, err := o.calculateAll(o.totpChallenge(), true)
func (c *Card) Calculate(name string, touchRequiredCallback func(string) error) (string, error) {
totpChallenge := c.totpChallenge()

codes, err := c.calculateAll(totpChallenge, true)
if err != nil {
return "", err
}

// Support matching by name without issuer in the same way that ykman does
// https://github.com/Yubico/yubikey-manager/blob/f493008d78a0ad09016f23dabd1cb658929d9c0e/ykman/cli/oath.py#L543
var key, code string
var key string
var code Code
var matches []string
for k, c := range res {
for k, c := range codes {
if strings.Contains(strings.ToLower(k), strings.ToLower(name)) {
key = k
code = c
matches = append(matches, k)
}
}
if len(matches) > 1 {
return "", fmt.Errorf("%w: %s", errMultipleMatches, strings.Join(matches, ","))
return "", fmt.Errorf("%w: %s", ErrMultipleMatches, strings.Join(matches, ","))
}

if key == "" {
return "", fmt.Errorf("%w: %s", errUnknownName, name)
return "", fmt.Errorf("%w: %s", ErrUnknownName, name)
}

if code == errTouchRequired.Error() {
if err := touchRequiredCallback(name); err != nil {
return "", err
if code.TouchRequired || code.Type == Hotp {
if code.TouchRequired {
if touchRequiredCallback == nil {
return "", ErrTouchCallbackRequired
}

if err := touchRequiredCallback(key); err != nil {
return "", err
}
}

pw, digits, err := o.calculate(key, o.totpChallenge(), true)
if err != nil {
return "", err
var challenge []byte
if code.Type == Totp {
challenge = totpChallenge
}

return otp(digits, pw), nil
if code, err = c.calculate(key, challenge, true); err != nil {
return "", err
}
}

return code, nil
return code.OTP(), nil
}

func (o *OATH) CalculateTOTP(name string) ([]byte, int, error) {
return o.CalculateHOTP(name, o.totpChallenge())
func (c *Card) CalculateDirect(name string) (string, error) {
d, err := c.calculate(name, c.totpChallenge(), true)
if err != nil {
return "", err
}

return d.OTP(), nil
}

func (o *OATH) CalculateHOTP(name string, challenge []byte) ([]byte, int, error) {
return o.calculate(name, challenge, false)
func (c *Card) CalculateRaw(name string, challenge []byte) ([]byte, int, error) {
d, err := c.calculate(name, challenge, false)
if err != nil {
return nil, -1, err
}

return d.Hash, d.Digits, nil
}

// calculate implements the "CALCULATE" instruction
func (o *OATH) calculate(name string, challenge []byte, truncate bool) ([]byte, int, error) {
func (c *Card) calculate(name string, challenge []byte, truncate bool) (Code, error) {
var trunc byte
if truncate {
trunc = 0x01
}

res, err := o.send(0x00, insCalculate, 0x00, trunc,
write(tagName, []byte(name)),
write(tagChallenge, challenge),
tvs, err := c.send(insCalculate, 0x00, trunc,
tlv.New(tagName, []byte(name)),
tlv.New(tagChallenge, challenge),
)
if err != nil {
return nil, 0, err
return Code{}, err
}

for _, tv := range res {
switch tv.tag {
for _, tv := range tvs {
switch tv.Tag {
case tagResponse, tagTruncated:
digits := int(tv.value[0])
hash := tv.value[1:]
return hash, digits, nil
digits := int(tv.Value[0])
hash := tv.Value[1:]
return Code{
Hash: hash,
Digits: digits,
Truncated: tv.Tag == tagTruncated,
}, nil

default:
return nil, 0, fmt.Errorf("%w: %x", errUnknownTag, tv.tag)
return Code{}, fmt.Errorf("%w: %x", errUnknownTag, tv.Tag)
}
}

return nil, 0, fmt.Errorf("%w: %x", errNoValuesFound, res)
return Code{}, ErrNoValuesFound
}

// calculateAll implements the "CALCULATE ALL" instruction to fetch all TOTP
// tokens and their codes (or a constant indicating a touch requirement)
func (o *OATH) calculateAll(challenge []byte, truncate bool) (map[string]string, error) {
func (c *Card) calculateAll(challenge []byte, truncate bool) (map[string]Code, error) {
var (
codes []string
codes []Code
names []string

trunc byte
Expand All @@ -114,32 +143,43 @@ func (o *OATH) calculateAll(challenge []byte, truncate bool) (map[string]string,
trunc = 0x01
}

res, err := o.send(0x00, insCalculateAll, 0x00, trunc,
write(tagChallenge, challenge),
tvs, err := c.send(insCalculateAll, 0x00, trunc,
tlv.New(tagChallenge, challenge),
)
if err != nil {
return nil, err
}

for _, tv := range res {
switch tv.tag {
for _, tv := range tvs {
switch tv.Tag {
case tagName:
names = append(names, string(tv.value))
names = append(names, string(tv.Value))

case tagTouch:
codes = append(codes, errTouchRequired.Error())
codes = append(codes, Code{
Type: Totp,
TouchRequired: true,
})

case tagResponse, tagTruncated:
digits := int(tv.value[0])
hash := tv.value[1:]
codes = append(codes, otp(digits, hash))
codes = append(codes, Code{
Type: Totp,
Hash: tv.Value[1:],
Digits: int(tv.Value[0]),
Truncated: tv.Tag == tagTruncated,
})

case tagHOTP:
codes = append(codes, Code{
Type: Hotp,
})

default:
return nil, fmt.Errorf("%w (%#x)", errUnknownTag, tv.tag)
return nil, fmt.Errorf("%w (%#x)", errUnknownTag, tv.Tag)
}
}

all := make(map[string]string, len(names))
all := make(map[string]Code, len(names))

for idx, name := range names {
all[name] = codes[idx]
Expand All @@ -148,13 +188,7 @@ func (o *OATH) calculateAll(challenge []byte, truncate bool) (map[string]string,
return all, nil
}

func (o *OATH) totpChallenge() []byte {
counter := o.Clock().Unix() / int64(o.Timestep.Seconds())
func (c *Card) totpChallenge() []byte {
counter := c.Clock().Unix() / int64(c.Timestep.Seconds())
return binary.BigEndian.AppendUint64(nil, uint64(counter))
}

// otp converts a value into a (6 or 8 digits) one-time password
func otp(digits int, hash []byte) string {
code := binary.BigEndian.Uint32(hash)
return fmt.Sprintf(fmt.Sprintf("%%0%dd", digits), code)
}
Loading