diff --git a/README.md b/README.md index b52cef0..d258175 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ shares: In this case, the file key will be split into three shares, each of which will be encrypted with one of the three recipients in the `shares` array. The `threshold` specifies how many of the shares are required to decrypt the file again. For our example, any two identities that correspond to a recipient in the list will suffice. -You can not only use native age recipients (see below how to handle passwords), but also plugin recipients! SSH recipients are not supported [yet](https://github.com/olastor/age-plugin-sss/issues/1). +You can not only use native age recipients (see below how to handle passwords), but also plugin or SSH recipients. Next, generate a new recipient from this policy file: @@ -95,23 +95,6 @@ it works ### Advanced Usage -#### Passwords - -Let's say you want to split the encryption key into shares wrapped by different passwords. As there is no recipient string you can generate, you must provide a special keyword of the form `password-`, e.g. - -```yaml -threshold: 3 -shares: - - password-alice - - password-bob - - password-chris -``` - -The plugin will ask you for each password upon encryption. Please notice that - -- the name/slug is required, but it is not persisted in the encrypted file. It's only there for you to not confuse passwords when interacting with the cli. -- the name/slug is mandatory for encryption, but optional for decryption (as the plugin will try all password stanzas until it finds the correct one). So having one or multiple identities named `password` is fine. - #### (More) Complex Policies Let's say you want to encrypt a file so that in addition to your X25519 identity you also need one of two fido2 keys for decryption. This can be achived by adding a nested policy in one of the shares as such: @@ -150,6 +133,51 @@ identities: identity: AGE-PLUGIN-FIDO2-HMAC-1VE5KGMEJ945X6CTRM2TF76 ``` +#### SSH Recipients/Identities + +It's also possible to include an SSH public key in a policy and an SSH private key in an identity. Make sure to use YAML's multi-line string formatting for the identity. + +**Example:** + +```yaml +# recipient.yaml +threshold: 1 +shares: + - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL84xOFSWXIcAeQK8CJ0qvHojdFZDuLGRe5FPg4aM3kY testing@local +``` + +``` +# identity.yaml +identities: + - | + -----BEGIN OPENSSH PRIVATE KEY----- + b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW + QyNTUxOQAAACC/OMThUllyHAHkCvAidKrx6I3RWQ7ixkXuRT4OGjN5GAAAAKCxGCybsRgs + mwAAAAtzc2gtZWQyNTUxOQAAACC/OMThUllyHAHkCvAidKrx6I3RWQ7ixkXuRT4OGjN5GA + AAAEBqlzBxbT+cd7xs19UN6ZFKG2bb4vtoR6/7FHt7yJ4DZ784xOFSWXIcAeQK8CJ0qvHo + jdFZDuLGRe5FPg4aM3kYAAAAGnNlYmFzdGlhbkBmZWRvcmEuZnJpdHouYm94AQID + -----END OPENSSH PRIVATE KEY----- +``` + +If the private key is passphrase protected, you will be prompted for the password, **but only if the encrypted private key contains the public key**. Otherwise, you'll need to create a version of your private key that is not passphrase encrypted (see [here](https://stackoverflow.com/a/112409)). This issue is tracked in [#3](https://github.com/olastor/age-plugin-sss/issues/3) + +#### Passwords + +Let's say you want to split the encryption key into shares wrapped by different passwords. As there is no recipient string you can generate, you must provide a special keyword of the form `password-`, e.g. + +```yaml +threshold: 3 +shares: + - password-alice + - password-bob + - password-chris +``` + +The plugin will ask you for each password upon encryption. Please notice that + +- the name/slug is required, but it is not persisted in the encrypted file. It's only there for you to not confuse passwords when interacting with the cli. +- the name/slug is mandatory for encryption, but optional for decryption (as the plugin will try all password stanzas until it finds the correct one). So having one or multiple identities named `password` is fine. + #### Converting recipients/identities back to YAML You can always decode a recipient or identity with the `--decode` flag by passing the string via STDIN: diff --git a/SPEC.md b/SPEC.md index 43960b4..502728c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -11,7 +11,7 @@ Shamir's Secret Sharing (SSS) [1] is a cryptographic scheme in which a secret _s ### Policies based on SSS -Recursive application of SSS enables more complex secret sharing structures in which shares themselves are splitted. This allows for the definition of arbitrarily complex trees that define sophisticated policies about which combinations of shares are eligble for secret recovery, and which are not. See [3] for an existing implementation. +Recursive application of SSS enables more complex secret sharing structures in which shares themselves are splitted. This allows for the definition of arbitrarily complex trees that define sophisticated policies about which combinations of shares are eligble for secret recovery, and which are not. See [3] for an existing implementation. #### Example @@ -96,7 +96,7 @@ age <---> age-plugin-sss <---> age-plugin-x #### SSH Recipients -TBA +SSH recipients/identities may be used, as well. In the identity file, the raw private key must be added as a multi-line string. The plugin must prompt for the password if the private key is passphrase protected and the public key is included in the [error](https://pkg.go.dev/golang.org/x/crypto/ssh#PassphraseMissingError). ### Conversion to YAML diff --git a/go.mod b/go.mod index 2a944a8..eaa2a5b 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( require github.com/rogpeppe/go-internal v1.11.0 require ( + filippo.io/edwards25519 v1.0.0 // indirect golang.org/x/crypto v0.15.0 // indirect golang.org/x/sys v0.15.0 // indirect golang.org/x/tools v0.10.0 // indirect diff --git a/go.sum b/go.sum index 76802b6..8e8b5f1 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ c2sp.org/CCTV/age v0.0.0-20221230231406-5ea85644bd03 h1:0e2QjhWG02SgzlUOvNYaFraf c2sp.org/CCTV/age v0.0.0-20221230231406-5ea85644bd03/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= filippo.io/age v1.1.2-0.20230920124100-101cc8676386 h1:cMNckh2AzWP8qr+i+WgCVteMLFoDOkLy+6AsYRWwfVU= filippo.io/age v1.1.2-0.20230920124100-101cc8676386/go.mod h1:y3Zb/i2jHg/kL8xc3ocrI0Wd0Vm+VWV6DKfsKzSGUmU= +filippo.io/edwards25519 v1.0.0 h1:0wAIcmJUqRdI8IJ/3eGi5/HwXZWPujYXXlkrQogz0Ek= +filippo.io/edwards25519 v1.0.0/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= github.com/hashicorp/vault v1.15.4 h1:VyHhGYIUIUTJC55DOsNsCpU8rbQ36veln/J/Zb7q8NM= github.com/hashicorp/vault v1.15.4/go.mod h1:Q/SaVsPCcXA8UXU+4hcXKYPhThBmw27qKZQpPCJivAc= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= diff --git a/pkg/sss/plugin.go b/pkg/sss/plugin.go index 299adbf..75cd128 100644 --- a/pkg/sss/plugin.go +++ b/pkg/sss/plugin.go @@ -280,7 +280,6 @@ func IdentityV1 () error { if err != nil { return false, fmt.Errorf("Error parsing identity: %s", err) } - identities = append(identities, parsedIdentity) case "recipient-stanza": if args[1] != "sss" { diff --git a/pkg/sss/policy.go b/pkg/sss/policy.go index 41cbf71..f2d8c47 100644 --- a/pkg/sss/policy.go +++ b/pkg/sss/policy.go @@ -5,6 +5,7 @@ import ( "errors" "strings" "filippo.io/age" + "filippo.io/age/agessh" "filippo.io/age/plugin" "github.com/hashicorp/vault/shamir" ) @@ -85,8 +86,12 @@ func (policy *SSS) Wrap(fileKey []byte) (stanza *SSSStanza, err error) { return nil, errors.New("missing recipient in policy") } + policy.Recipient = strings.TrimSpace(policy.Recipient) + var wrappedShare []*age.Stanza - if strings.HasPrefix(policy.Recipient, "password-") { + + switch { + case strings.HasPrefix(policy.Recipient, "password-"): if policy.Recipient == "password-" { return nil, errors.New("missing identifier for password") } @@ -116,28 +121,38 @@ func (policy *SSS) Wrap(fileKey []byte) (stanza *SSSStanza, err error) { if err != nil { return nil, err } - } - - if wrappedShare == nil { + case strings.HasPrefix(policy.Recipient, "age1") && strings.Count(policy.Recipient, "1") > 1: pluginRecipient, err := plugin.NewRecipient(policy.Recipient, PluginTerminalUIProxy) - if err == nil { - wrappedShare, err = pluginRecipient.Wrap(fileKey) - - if err != nil { - return nil, err - } + if err != nil { + return nil, err } - } - if wrappedShare == nil { + wrappedShare, err = pluginRecipient.Wrap(fileKey) + if err != nil { + return nil, err + } + case strings.HasPrefix(policy.Recipient, "age1"): x25519Recipient, err := age.ParseX25519Recipient(policy.Recipient) - if err == nil { - wrappedShare, err = x25519Recipient.Wrap(fileKey) + if err != nil { + return nil, err + } - if err != nil { - return nil, err - } + wrappedShare, err = x25519Recipient.Wrap(fileKey) + if err != nil { + return nil, err + } + case strings.HasPrefix(policy.Recipient, "ssh-"): + sshRecipient, err := agessh.ParseRecipient(policy.Recipient) + if err != nil { + return nil, err + } + + wrappedShare, err = sshRecipient.Wrap(fileKey) + if err != nil { + return nil, err } + default: + return nil, fmt.Errorf("unsupported recipient %s", policy.Recipient) } if wrappedShare == nil { diff --git a/pkg/sss/stanza.go b/pkg/sss/stanza.go index dd9572e..eebb641 100644 --- a/pkg/sss/stanza.go +++ b/pkg/sss/stanza.go @@ -7,7 +7,9 @@ import ( "errors" "sort" "strconv" + "golang.org/x/crypto/ssh" "filippo.io/age" + "filippo.io/age/agessh" "filippo.io/age/plugin" "github.com/hashicorp/vault/shamir" ) @@ -76,15 +78,15 @@ func (stanza *SSSStanza) recoverSecret () (fileKey []byte, err error) { } if keyShare != nil { + if stanza.Threshold == 1 { + // the share is the secret + return keyShare, nil + } + shares = append(shares, getShareWithX(keyShare, share)) } } - // no shamir required for t=1 - if stanza.Threshold == 1 && len(shares) > 0 { - return shares[0], nil - } - return shamir.Combine(shares) } @@ -226,7 +228,37 @@ func (stanza *SSSStanza) Unwrap (identity *SSSIdentity) (data []byte, err error) return nil, err } default: - continue + // check if it's an SSH identity + pemBytes := []byte(strings.TrimSpace(id.IdentityStr)) + id.Identity, err = agessh.ParseIdentity(pemBytes) + if err != nil { + switch v := err.(type) { + case *ssh.PassphraseMissingError: + if v.PublicKey == nil { + // we need the pubkey to unlock the ssh key + return nil, err + } + + id.Identity, err = agessh.NewEncryptedSSHIdentity(v.PublicKey, pemBytes, func () ([]byte, error) { + passphrase, err := RequestValue("Please enter the password for your SSH key:", true) + if err != nil { + return nil, err + } + + return []byte(passphrase), nil + }) + + if err != nil { + return nil, err + } + default: + return nil, err + } + } + + if id.Identity == nil { + return nil, fmt.Errorf("Unknown identity at index %x of list", i) + } } stanza.unwrapKeyShare(id) diff --git a/testdata/decode.txt b/testdata/decode.txt index 44d6ac9..d43a888 100644 --- a/testdata/decode.txt +++ b/testdata/decode.txt @@ -1,4 +1,4 @@ -# encrypt and decrypt with a simple policy +# decode recipients/identities exec age-plugin-sss --generate-recipient policy.yaml cmp stdout policy.pub ! stderr . diff --git a/testdata/nested-2.txt b/testdata/nested-2.txt index b29ba13..af86789 100644 --- a/testdata/nested-2.txt +++ b/testdata/nested-2.txt @@ -1,4 +1,4 @@ -# encrypt and decrypt with a simple policy +# encrypt and decrypt with a nested policy exec age-plugin-sss --generate-recipient policy.yaml cmp stdout recipient.txt ! stderr . diff --git a/testdata/nested.txt b/testdata/nested.txt index da7dd5b..811aa11 100644 --- a/testdata/nested.txt +++ b/testdata/nested.txt @@ -1,4 +1,4 @@ -# encrypt and decrypt with a simple policy +# encrypt and decrypt with a nested policy exec age-plugin-sss --generate-recipient policy.yaml cmp stdout recipient.txt ! stderr . diff --git a/testdata/ssh.txt b/testdata/ssh.txt new file mode 100644 index 0000000..1c06d37 --- /dev/null +++ b/testdata/ssh.txt @@ -0,0 +1,29 @@ +# encrypt/decrypt with ssh +exec sh -c 'age-plugin-sss --generate-recipient policy.yaml > recipient.txt' +! stderr . + +exec sh -c 'age-plugin-sss --generate-identity identity.yaml > identity.txt' +! stderr . + +exec age -R recipient.txt -o test.age input +! stderr . + +exec age -d -i identity.txt test.age +cmp stdout input +! stderr . +-- input -- +it works +-- policy.yaml -- +threshold: 1 +shares: + - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL84xOFSWXIcAeQK8CJ0qvHojdFZDuLGRe5FPg4aM3kY testing@local +-- identity.yaml -- +identities: + - | + -----BEGIN OPENSSH PRIVATE KEY----- + b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW + QyNTUxOQAAACC/OMThUllyHAHkCvAidKrx6I3RWQ7ixkXuRT4OGjN5GAAAAKCxGCybsRgs + mwAAAAtzc2gtZWQyNTUxOQAAACC/OMThUllyHAHkCvAidKrx6I3RWQ7ixkXuRT4OGjN5GA + AAAEBqlzBxbT+cd7xs19UN6ZFKG2bb4vtoR6/7FHt7yJ4DZ784xOFSWXIcAeQK8CJ0qvHo + jdFZDuLGRe5FPg4aM3kYAAAAGnNlYmFzdGlhbkBmZWRvcmEuZnJpdHouYm94AQID + -----END OPENSSH PRIVATE KEY-----