Skip to content

Commit

Permalink
feat: add support for SSH recipients/identities
Browse files Browse the repository at this point in the history
* fixed issue when using a threshold of 1 in the top-level (it tried to
  add the X value of the share, which was wrong)
* ssh pubkeys are supported for wrapping
* ssh privkeys can be included in identities
* passphrase prompt for protected privkeys (but only if pubkey is in
  error object)
  • Loading branch information
olastor committed Jan 13, 2024
1 parent 963102b commit 97b86d0
Show file tree
Hide file tree
Showing 11 changed files with 153 additions and 47 deletions.
64 changes: 46 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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-<name-or-slug>`, 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:
Expand Down Expand Up @@ -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-<name-or-slug>`, 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:
Expand Down
4 changes: 2 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 0 additions & 1 deletion pkg/sss/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
49 changes: 32 additions & 17 deletions pkg/sss/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"strings"
"filippo.io/age"
"filippo.io/age/agessh"
"filippo.io/age/plugin"
"github.com/hashicorp/vault/shamir"
)
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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 {
Expand Down
44 changes: 38 additions & 6 deletions pkg/sss/stanza.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion testdata/decode.txt
Original file line number Diff line number Diff line change
@@ -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 .
Expand Down
2 changes: 1 addition & 1 deletion testdata/nested-2.txt
Original file line number Diff line number Diff line change
@@ -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 .
Expand Down
2 changes: 1 addition & 1 deletion testdata/nested.txt
Original file line number Diff line number Diff line change
@@ -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 .
Expand Down
29 changes: 29 additions & 0 deletions testdata/ssh.txt
Original file line number Diff line number Diff line change
@@ -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-----

0 comments on commit 97b86d0

Please sign in to comment.