Compare commits

...

8 Commits
v0.1.0 ... main

Author SHA1 Message Date
jolheiser 906de83072
Refactor tests
continuous-integration/woodpecker the build was successful Details
ci/woodpecker/tag/woodpecker Pipeline was successful Details
Signed-off-by: jolheiser <john.olheiser@gmail.com>
2021-11-23 22:46:37 -06:00
jolheiser f27be2ffff
Better parsing error for scope and template
continuous-integration/woodpecker the build was successful Details
Signed-off-by: jolheiser <john.olheiser@gmail.com>
2021-11-23 22:20:41 -06:00
jolheiser 2514b79913
Add tests for CLI
continuous-integration/woodpecker the build was successful Details
Signed-off-by: jolheiser <john.olheiser@gmail.com>
2021-11-23 22:08:40 -06:00
jolheiser 5871595462
Use fs args in CLI
continuous-integration/woodpecker the build was successful Details
Signed-off-by: jolheiser <john.olheiser@gmail.com>
2021-11-23 21:47:01 -06:00
jolheiser 7e73fb5238
Use binary.BigEndian and attribute source
continuous-integration/woodpecker the build was successful Details
Signed-off-by: jolheiser <john.olheiser@gmail.com>
2021-11-21 21:45:09 -06:00
jolheiser 0e965a5dfa
Clarify scopes, templates, and characters
continuous-integration/woodpecker the build was successful Details
Signed-off-by: jolheiser <john.olheiser@gmail.com>
2021-11-21 21:23:54 -06:00
jolheiser c33188090a
Add implementation
continuous-integration/woodpecker the build was successful Details
Signed-off-by: jolheiser <john.olheiser@gmail.com>
2021-11-21 10:04:42 -06:00
jolheiser 153a69a95e
Add examples and CLI readme
continuous-integration/woodpecker the build was successful Details
Signed-off-by: jolheiser <john.olheiser@gmail.com>
2021-11-10 16:15:57 -06:00
15 changed files with 429 additions and 140 deletions

View File

@ -17,9 +17,6 @@ pipeline:
commands:
- GOOS="windows" go build ./cmd/spectre
- GOOS="linux" go build ./cmd/spectre
when:
event: [ push, tag ]
branch: main
release-main:
image: jolheiser/drone-gitea-main:latest

142
IMPLEMENTATION.md 100644
View File

@ -0,0 +1,142 @@
# Implementation
Adapted from [source](https://spectre.app/blog/2018-01-06-algorithm/).
## User Key Derivation
([code](user_key.go))
A user's key should be generated at "construction" time of a potential client. The user key derivation step is the
longer part of the generation and should be separated from the cheaper site key and password generation steps.
### Variables
* `user_name`
* `user_secret`
### Steps
1. `user_name` as byte slice
2. `user_secret` as byte slice
3. `key_scope` ([Authentication](#scopes)) as byte slice
4. `key_salt` = `key_scope` + `len(user_name)` (big endian) + `user_name`
5. `user_key` = `scrypt(password = user_secret, salt = key_salt, N = 32768, r = 8, p = 2, key_length = 64)`
Store the `user_key` for use later.
## Site Key Generation
([code](site_key.go))
The site key generation step is part of getting a password and is used against a [template](#templates).
### Variables
* `site_name`
* `counter`
* [`scope`](#scopes)
### Steps
1. `site_name` as byte slice
2. `scope` as byte slice
3. `key_salt` = `scope` + `len(site_name)` (big endian) + `site_name` + `counter` (big endian)
4. `site_key` = `HMAC-SHA256(key = user_key, data = key_salt)`
## Site Password Generation
([code](site_password.go))
### Variables
* `site_name`
* `counter` (default is `1`)
* `scope` (default is [Authentication](#scopes))
* `template_type` (default is [based on scope](#scopes))
### Steps
1. `site_key` as byte slice
2. `template_set` = `templates[ template_type ]` ([templates](#templates))
3. `template` = `template_set[ site_key[0] % len(template_set) ]`
4. for each character `b` in the template (`loop_index` starting at `0`)
1. `chars` = `characters[ b ]` ([characters](#characters))
2. `char` = `chars[ site_key[ loop_index + 1 ] % len(chars) ]`
3. add `char` to output string
## Walkthrough
* `user_name` = `Robert Lee Mitchell`
* `user_secret` = `banana colored duckling`
* `scope` = [Authentication](#scopes)
* `counter` = `1`
* `template` = [Long](#scopes)
* `site_name` = `masterpasswordapp.com`
### User Key
```text
[24 76 42 206 37 187 113 129 122 202 164 134 75 113 147 21 177 89 17 50 52 178 162 191 86 144 232 125 103 172 42 251 195 72 15 109 194 103 28 206 230 240 192 133 230 226 64 32 195 166 175 242 54 123 217 242 58 194 205 104 168 74 95 194]
```
### Site Key
```text
[18 27 156 216 202 205 54 139 226 53 64 140 63 35 242 105 24 249 162 30 135 30 0 50 101 141 213 27 212 150 120 210]
```
### Site Password
`Jejr5[RepuSosp`
## Appendix
### Scopes
([code](scope.go))
Scopes are chosen by a user (default **Authentication**) to denote what type of password they need.
|Scope|Value|Default Template|
|:---:|:---:|:---:|
|Authentication|`com.lyndir.masterpassword`|Long|
|Identification|`com.lyndir.masterpaswword.login`|Name|
|Recovery|`com.lyndir.masterpassword.answer`|Phrase|
**NOTE:** One difference in my implementations compared to the original is allowing consumers to choose
their own "scoper", some interface that can respond to the three scopes with whatever response they choose.
**This means that any custom implementation will not return the same results, so keep that in mind when using
a service.**
### Templates
([code](template.go))
Template sets are used when generating a password, each character relates to a corresponding set in [characters](#characters).
|Template|Sets|
|:---:|:---:|
|Maximum| `["anoxxxxxxxxxxxxxxxxx", "axxxxxxxxxxxxxxxxxno"]`|
|Long|`["CvcvnoCvcvCvcv", "CvcvCvcvnoCvcv", "CvcvCvcvCvcvno", "CvccnoCvcvCvcv", "CvccCvcvnoCvcv", "CvccCvcvCvcvno", "CvcvnoCvccCvcv", "CvcvCvccnoCvcv", "CvcvCvccCvcvno", "CvcvnoCvcvCvcc", "CvcvCvcvnoCvcc", "CvcvCvcvCvccno", "CvccnoCvccCvcv", "CvccCvccnoCvcv", "CvccCvccCvcvno", "CvcvnoCvccCvcc", "CvcvCvccnoCvcc", "CvcvCvccCvccno", "CvccnoCvcvCvcc", "CvccCvcvnoCvcc", "CvccCvcvCvccno"]`|
|Medium|`["CvcnoCvc", "CvcCvcno"]`|
|Short|`["Cvcn"]`|
|Pin|`["nnnn"]`|
|Name|`["cvccvcvcv"]`|
|Phrase|`["cvcc cvc cvccvcv cvc", "cvc cvccvcvcv cvcv", "cv cvccv cvc cvcvccv"]`|
|Basic|`["aaanaaan", "aannaaan", "aaannaaa"]`|
### Characters
([code](template.go))
Characters are sets of characters used in a [Template](#templates).
|Symbol|Set|
|:---:|:---:|
|V|`AEIOU`|
|C|`BCDFGHJKLMNPQRSTVWXYZ`|
|v|`aeiou`|
|c|`bcdfghjklmnpqrstvwxyz`|
|A|`AEIOUBCDFGHJKLMNPQRSTVWXYZ`|
|a|`AEIOUaeiouBCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz`|
|n|`0123456789`|
|o|`@&%?,=[]_:-+*$#!'^~;()/.`|
|x|`AEIOUaeiouBCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz0123456789!@#$%^&*()`|
|(literal space)|` `|

View File

@ -9,6 +9,10 @@ It also passes the JS [sanity check](https://gitlab.com/spectre.app/www/-/blob/3
This is because I've only implemented v3 of the algorithm and the main pieces.
[Implementation breakdown](IMPLEMENTATION.md)
Generally code wouldn't split across files this liberally. It's done here for ease of reference.
## License
[GPLv3](LICENSE) - same as the algorithm author

View File

@ -0,0 +1,33 @@
# Spectre CLI
All flags can be supplied as environment variables starting with `SPECTRE_*`
e.g. `--username` -> `SPECTRE_USERNAME`
### Usage
```text
spectre [FLAGS] [site]
-counter int
counter (default 1)
-scope value
scope
-scoper string
scoper base (default "com.lyndir.masterpassword")
-secret string
secret
-template value
template
-username string
username
```
### Example
```shell
export SPECTRE_USERNAME="Robert Lee Mitchell"
export SPECTRE_SECRET="banana colored duckling"
spectre masterpasswordapp.com
# Jejr5[RepuSosp
```

View File

@ -10,10 +10,22 @@ import (
)
func main() {
pw, err := doMain(os.Args[1:])
if err != nil {
fmt.Println(err)
return
}
fmt.Println(pw)
}
func doMain(args []string) (string, error) {
fs := flag.NewFlagSet("spectre", flag.ExitOnError)
fs.Usage = func() {
fmt.Fprintln(fs.Output(), "spectre [FLAGS] [site]")
fs.PrintDefaults()
}
usernameFlag := fs.String("username", "", "username")
secretFlag := fs.String("secret", "", "secret")
siteFlag := fs.String("site", "", "site")
counterFlag := fs.Int("counter", 1, "counter")
scoperFlag := fs.String("scoper", "com.lyndir.masterpassword", "scoper base")
scopeFlag := spectre.Authentication
@ -27,35 +39,57 @@ func main() {
return
})
if err := fs.Parse(os.Args[1:]); err != nil {
panic(err)
if err := fs.Parse(args); err != nil {
return "", err
}
if err := checkEnv(fs); err != nil {
panic(err)
return "", err
}
if templateFlag == "" {
templateFlag = scopeFlag.DefaultTemplate()
}
if *usernameFlag == "" || *secretFlag == "" || *siteFlag == "" {
panic("username, secret, and site are required")
if *usernameFlag == "" || *secretFlag == "" || fs.NArg() < 1 {
return "", requiredArgs{
missingUsername: *usernameFlag == "",
missingSecret: *secretFlag == "",
missingSite: fs.NArg() < 1,
}
}
s, err := spectre.New(*usernameFlag, *secretFlag, spectre.WithScoper(spectre.SimpleScoper{
Key: *scoperFlag,
}))
if err != nil {
panic(err)
return "", err
}
pw := s.Site(*siteFlag,
return s.Site(fs.Arg(0),
spectre.WithScope(scopeFlag),
spectre.WithTemplate(templateFlag),
spectre.WithCounter(*counterFlag),
)
), nil
}
fmt.Println(pw)
type requiredArgs struct {
missingUsername bool
missingSecret bool
missingSite bool
}
func (r requiredArgs) Error() string {
s := "--username, --secret, and <site-name> are required, missing: "
if r.missingUsername {
s += "\n- username"
}
if r.missingSecret {
s += "\n- secret"
}
if r.missingSite {
s += "\n- site name"
}
return s
}
func checkEnv(fs *flag.FlagSet) error {

View File

@ -0,0 +1,41 @@
package main
import (
_ "embed"
"go.jolheiser.com/go-spectre/testdata"
"testing"
)
// These are the exact same tests as spectre_test.go
// These are here just to make sure the CLI is giving the same outputs
func TestCLI(t *testing.T) {
cases, err := testdata.Cases()
if err != nil {
t.Log(err)
t.FailNow()
}
for _, tc := range cases {
t.Run(tc.ID, func(t *testing.T) {
args := []string{
"--username", tc.UserName,
"--secret", tc.UserSecret,
"--template", tc.ResultType,
"--counter", tc.KeyCounter,
"--scope", tc.KeyPurpose,
tc.SiteName,
}
pw, err := doMain(args)
if err != nil {
t.Log(err)
t.FailNow()
}
if pw != tc.Result {
t.Log("passwords did not match")
t.Fail()
}
})
}
}

View File

@ -1,7 +1,7 @@
package spectre
import (
"errors"
"fmt"
"strings"
)
@ -41,7 +41,7 @@ func ParseScope(s string) (Scope, error) {
case "recovery", "r":
return Recovery, nil
default:
return "", errors.New("unknown Scope")
return "", fmt.Errorf("unknown Scope %q; available [authentication, identification, recovery]", s)
}
}

20
site_key.go 100644
View File

@ -0,0 +1,20 @@
package spectre
import (
"crypto/hmac"
"crypto/sha256"
)
func siteKey(userKey []byte, scoper Scoper, siteName string, counter int, scope Scope) []byte {
nameBytes := []byte(siteName)
scopeBytes := []byte(scoper.Scope(scope))
nameBytesLen := len(nameBytes)
keySalt := append(scopeBytes, bigEndian(nameBytesLen)...)
keySalt = append(keySalt, nameBytes...)
keySalt = append(keySalt, bigEndian(counter)...)
sign := hmac.New(sha256.New, userKey)
sign.Write(keySalt)
return sign.Sum(nil)
}

31
site_password.go 100644
View File

@ -0,0 +1,31 @@
package spectre
import "strings"
func site(userKey []byte, scoper Scoper, siteName string, opts ...SiteOption) string {
siteOpts := &options{
template: "",
counter: 1,
scope: Authentication,
}
for _, opt := range opts {
opt(siteOpts)
}
if siteOpts.template == "" {
siteOpts.template = siteOpts.scope.DefaultTemplate()
}
siteKey := siteKey(userKey, scoper, siteName, siteOpts.counter, siteOpts.scope)
templateSet := templates[siteOpts.template]
template := templateSet[int(siteKey[0])%len(templateSet)]
var out strings.Builder
for idx, b := range template {
chars := characters[string(b)]
char := chars[int(siteKey[idx+1])%len(chars)]
out.WriteByte(char)
}
return out.String()
}

View File

@ -1,11 +1,6 @@
package spectre
import (
"crypto/hmac"
"crypto/sha256"
"golang.org/x/crypto/scrypt"
"strings"
)
import "encoding/binary"
// Spectre is a spectre client
type Spectre struct {
@ -26,7 +21,7 @@ func New(name, secret string, opts ...Option) (s *Spectre, err error) {
for _, opt := range opts {
opt(s)
}
s.key, err = s.userKey()
s.key, err = userKey(s.name, s.secret, s.scoper)
return
}
@ -40,75 +35,9 @@ func WithScoper(scoper Scoper) Option {
}
}
func (s *Spectre) userKey() ([]byte, error) {
nameBytes := []byte(s.name)
secretBytes := []byte(s.secret)
keyScope := []byte(s.scoper.Scope(Authentication))
nameBytesLen := len(nameBytes)
keySalt := append(keyScope,
byte(nameBytesLen>>24),
byte(nameBytesLen>>16),
byte(nameBytesLen>>8),
byte(nameBytesLen),
)
keySalt = append(keySalt, nameBytes...)
return scrypt.Key(secretBytes, keySalt, 32768, 8, 2, 64)
}
func (s *Spectre) siteKey(name string, counter int, scope Scope) []byte {
nameBytes := []byte(name)
scopeBytes := []byte(s.scoper.Scope(scope))
nameBytesLen := len(nameBytes)
keySalt := append(scopeBytes,
byte(nameBytesLen>>24),
byte(nameBytesLen>>16),
byte(nameBytesLen>>8),
byte(nameBytesLen),
)
keySalt = append(keySalt, nameBytes...)
keySalt = append(keySalt,
byte(counter>>24),
byte(counter>>16),
byte(counter>>8),
byte(counter),
)
sign := hmac.New(sha256.New, s.key)
sign.Write(keySalt)
return sign.Sum(nil)
}
// Site returns a site password based on Options
func (s *Spectre) Site(siteName string, opts ...SiteOption) string {
siteOpts := &options{
template: "",
counter: 1,
scope: Authentication,
}
for _, opt := range opts {
opt(siteOpts)
}
if siteOpts.template == "" {
siteOpts.template = siteOpts.scope.DefaultTemplate()
}
siteKey := s.siteKey(siteName, siteOpts.counter, siteOpts.scope)
templateSet := templates[siteOpts.template]
template := templateSet[int(siteKey[0])%len(templateSet)]
var out strings.Builder
for idx, b := range template {
chars := characters[string(b)]
char := chars[int(siteKey[idx+1])%len(chars)]
out.WriteByte(char)
}
return out.String()
return site(s.key, s.scoper, siteName, opts...)
}
type options struct {
@ -140,3 +69,9 @@ func WithScope(s Scope) SiteOption {
opts.scope = s
}
}
func bigEndian(num int) []byte {
buf := make([]byte, 4)
binary.BigEndian.PutUint32(buf, uint32(num))
return buf
}

View File

@ -2,7 +2,8 @@ package spectre_test
import (
_ "embed"
"encoding/xml"
"fmt"
"go.jolheiser.com/go-spectre/testdata"
"strconv"
"testing"
@ -10,38 +11,29 @@ import (
)
func TestSpectre(t *testing.T) {
var tests TestCases
if err := xml.Unmarshal(testsXML, &tests); err != nil {
t.Log("could not load test data")
cases, err := testdata.Cases()
if err != nil {
t.Log(err)
t.FailNow()
}
dc := tests.Cases[0]
for _, tc := range tests.Cases[1:] {
for _, tc := range cases {
t.Run(tc.ID, func(t *testing.T) {
user := def(dc.UserName, tc.UserName)
secret := def(dc.UserSecret, tc.UserSecret)
s, err := spectre.New(user, secret)
s, err := spectre.New(tc.UserName, tc.UserSecret)
if err != nil {
t.Logf("could not initialize spectre: %v", err)
t.Fail()
}
siteName := def(dc.SiteName, tc.SiteName)
template := def(dc.ResultType, tc.ResultType)
counterStr := def(dc.KeyCounter, tc.KeyCounter)
counter, err := strconv.Atoi(counterStr)
counter, err := strconv.Atoi(tc.KeyCounter)
if err != nil {
t.Log("could not convert counter")
t.Fail()
}
scope := def(dc.KeyPurpose, tc.KeyPurpose)
pass := s.Site(siteName,
spectre.WithTemplate(spectre.Template(template)),
pass := s.Site(tc.SiteName,
spectre.WithTemplate(spectre.Template(tc.ResultType)),
spectre.WithCounter(counter),
spectre.WithScope(spectre.Scope(scope)),
spectre.WithScope(spectre.Scope(tc.KeyPurpose)),
)
if pass != tc.Result {
@ -53,41 +45,32 @@ func TestSpectre(t *testing.T) {
}
// From the website sanity check
func TestSanity(t *testing.T) {
func Example() {
s, err := spectre.New("Robert Lee Mitchell", "banana colored duckling")
if err != nil {
t.Logf("failed sanity check: %v", err)
t.FailNow()
panic(err)
}
pw := s.Site("masterpasswordapp.com")
if pw != "Jejr5[RepuSosp" {
t.Log("failed sanity check")
t.FailNow()
fmt.Println(pw)
// Output: Jejr5[RepuSosp
}
// Example with options
func Example_second() {
scoper := spectre.SimpleScoper{
Key: "com.jojodev.jolheiser",
}
}
//go:embed spectre_tests.xml
var testsXML []byte
type TestCases struct {
Cases []TestCase `xml:"case"`
}
type TestCase struct {
ID string `xml:"id,attr"`
UserName string `xml:"userName"`
UserSecret string `xml:"userSecret"`
SiteName string `xml:"siteName"`
ResultType string `xml:"resultType"`
KeyCounter string `xml:"keyCounter"`
KeyPurpose string `xml:"keyPurpose"`
Result string `xml:"result"`
}
func def(def, alt string) string {
if alt != "" {
return alt
s, err := spectre.New("Robert Lee Mitchell", "banana colored duckling", spectre.WithScoper(scoper))
if err != nil {
panic(err)
}
return def
pw := s.Site("jojodev.com",
spectre.WithScope(spectre.Identification),
spectre.WithTemplate(spectre.Maximum),
spectre.WithCounter(2), // Password was leaked, so increment counter (example)
)
fmt.Println(pw)
// Output: Ig^JIcxD!*)TbefJBi6-
}

View File

@ -1,7 +1,7 @@
package spectre
import (
"errors"
"fmt"
"strings"
)
@ -39,7 +39,7 @@ func ParseTemplate(s string) (Template, error) {
case "basic":
return Basic, nil
default:
return "", errors.New("unknown Template")
return "", fmt.Errorf("unknown Template %q; available [maximum, long, medium, short, pin, name, phrase, basic]", s)
}
}

54
testdata/testdata.go vendored 100644
View File

@ -0,0 +1,54 @@
package testdata
import (
_ "embed"
"encoding/xml"
)
func Cases() ([]*TestCase, error) {
var tests TestCases
if err := xml.Unmarshal(testsXML, &tests); err != nil {
return nil, err
}
defaultCase := tests.Cases[0]
cases := make([]*TestCase, 0, len(tests.Cases[1:]))
for _, tc := range tests.Cases[1:] {
tc = &TestCase{
ID: tc.ID,
UserName: def(defaultCase.UserName, tc.UserName),
UserSecret: def(defaultCase.UserSecret, tc.UserSecret),
SiteName: def(defaultCase.SiteName, tc.SiteName),
ResultType: def(defaultCase.ResultType, tc.ResultType),
KeyCounter: def(defaultCase.KeyCounter, tc.KeyCounter),
KeyPurpose: def(defaultCase.KeyPurpose, tc.KeyPurpose),
Result: tc.Result,
}
cases = append(cases, tc)
}
return cases, nil
}
//go:embed spectre_tests.xml
var testsXML []byte
type TestCases struct {
Cases []*TestCase `xml:"case"`
}
type TestCase struct {
ID string `xml:"id,attr"`
UserName string `xml:"userName"`
UserSecret string `xml:"userSecret"`
SiteName string `xml:"siteName"`
ResultType string `xml:"resultType"`
KeyCounter string `xml:"keyCounter"`
KeyPurpose string `xml:"keyPurpose"`
Result string `xml:"result"`
}
func def(def, alt string) string {
if alt != "" {
return alt
}
return def
}

15
user_key.go 100644
View File

@ -0,0 +1,15 @@
package spectre
import "golang.org/x/crypto/scrypt"
func userKey(name, secret string, scoper Scoper) ([]byte, error) {
nameBytes := []byte(name)
secretBytes := []byte(secret)
keyScope := []byte(scoper.Scope(Authentication))
nameBytesLen := len(nameBytes)
keySalt := append(keyScope, bigEndian(nameBytesLen)...)
keySalt = append(keySalt, nameBytes...)
return scrypt.Key(secretBytes, keySalt, 32768, 8, 2, 64)
}