Skip to content

Commit 4088b6a

Browse files
committed
adding age plugin wrapping default X25519 Identity/Recipient implementation with hooks to more efficiently lookup private keys given their respective public key
1 parent c8a2b66 commit 4088b6a

File tree

2 files changed

+224
-0
lines changed

2 files changed

+224
-0
lines changed

lib/auth/recordingencryption/age.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Teleport
2+
// Copyright (C) 2025 Gravitational, Inc.
3+
//
4+
// This program is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Affero General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// This program is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Affero General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Affero General Public License
15+
// along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
17+
package recordingencryption
18+
19+
import (
20+
"filippo.io/age"
21+
"github.com/gravitational/trace"
22+
23+
"github.com/gravitational/teleport/api/types"
24+
)
25+
26+
// X25519Stanza is the default stanza type used by age.
27+
const X25519Stanza = "X25519"
28+
29+
// RecordingStanza is the type used for the identifying stanza added by RecordingRecipient.
30+
const RecordingStanza = "Recording-X25519"
31+
32+
// DecryptionKeyFinder returns an EncryptionKeyPair related to at least one of the given public keys to be used
33+
// for file key unwrapping.
34+
type DecryptionKeyFinder interface {
35+
FindDecryptionKey(publicKeys ...[]byte) (*types.EncryptionKeyPair, error)
36+
}
37+
38+
// RecordingIdentity removes public keys from stanzas and passes the unwrap call to the default
39+
// age.X25519Identity.
40+
type RecordingIdentity struct {
41+
keyFinder DecryptionKeyFinder
42+
}
43+
44+
// NewRecordingIdentity returns a RecordingIdentity that will use the given DecryptionKeyFinder in order to facilitate
45+
// file key unwrapping.
46+
func NewRecordingIdentity(keyFinder DecryptionKeyFinder) *RecordingIdentity {
47+
return &RecordingIdentity{
48+
keyFinder: keyFinder,
49+
}
50+
}
51+
52+
// Unwrap uses the additional stanzas added by RecordingRecipient.Wrap in order to find a matching X25519 identity.
53+
func (i *RecordingIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
54+
var publicKeys [][]byte
55+
for _, stanza := range stanzas {
56+
if stanza.Type != RecordingStanza {
57+
continue
58+
}
59+
60+
if len(stanza.Args) != 1 {
61+
continue
62+
}
63+
64+
publicKeys = append(publicKeys, []byte(stanza.Args[0]))
65+
}
66+
67+
pair, err := i.keyFinder.FindDecryptionKey(publicKeys...)
68+
if err != nil {
69+
return nil, trace.Wrap(err)
70+
}
71+
72+
identity, err := age.ParseX25519Identity(string(pair.PrivateKey))
73+
if err != nil {
74+
return nil, trace.Wrap(err)
75+
}
76+
77+
return identity.Unwrap(stanzas)
78+
}
79+
80+
// RecordingRecipient adds the public key to the stanzas generated by the default age.X25519Recipient
81+
type RecordingRecipient struct {
82+
*age.X25519Recipient
83+
}
84+
85+
// ParseRecordingRecipient parses an Bech32 encoded age X25519 public key into a RecordingRecipient.
86+
func ParseRecordingRecipient(s string) (*RecordingRecipient, error) {
87+
recipient, err := age.ParseX25519Recipient(s)
88+
if err != nil {
89+
return nil, trace.Wrap(err)
90+
}
91+
92+
return &RecordingRecipient{X25519Recipient: recipient}, nil
93+
}
94+
95+
// Wrap a fileKey using the wrapped X2519Recipient. An additional stanza containing the bech32 encoded X25519
96+
// public key will be created to enable lookups during Unwrap.
97+
func (r *RecordingRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
98+
stanzas, err := r.X25519Recipient.Wrap(fileKey)
99+
if err != nil {
100+
return nil, trace.Wrap(err)
101+
}
102+
103+
// a new stanza has to be added because modifying the original stanza and returning it to "normal" during
104+
// Unwrap fails due to MAC errors
105+
for _, stanza := range stanzas {
106+
if stanza.Type == X25519Stanza {
107+
stanzas = append(stanzas, &age.Stanza{
108+
Type: RecordingStanza,
109+
Args: []string{r.String()},
110+
})
111+
}
112+
}
113+
114+
return stanzas, nil
115+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Teleport
2+
// Copyright (C) 2025 Gravitational, Inc.
3+
//
4+
// This program is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Affero General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// This program is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Affero General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Affero General Public License
15+
// along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
17+
package recordingencryption_test
18+
19+
import (
20+
"bytes"
21+
"errors"
22+
"io"
23+
"testing"
24+
25+
"filippo.io/age"
26+
"github.com/stretchr/testify/require"
27+
28+
"github.com/gravitational/teleport/api/types"
29+
"github.com/gravitational/teleport/lib/auth/recordingencryption"
30+
)
31+
32+
func TestRecordingAgePlugin(t *testing.T) {
33+
keyStore := newFakeKeyStore()
34+
recordingIdentity := recordingencryption.NewRecordingIdentity(keyStore)
35+
36+
ident, err := keyStore.generateIdentity()
37+
require.NoError(t, err)
38+
39+
recipient, err := recordingencryption.ParseRecordingRecipient(ident.Recipient().String())
40+
require.NoError(t, err)
41+
42+
out := bytes.NewBuffer(nil)
43+
writer, err := age.Encrypt(out, recipient)
44+
require.NoError(t, err)
45+
46+
msg := []byte("testing age plugin for session recordings")
47+
_, err = writer.Write(msg)
48+
require.NoError(t, err)
49+
50+
// writer must be closed to ensure data is flushed
51+
err = writer.Close()
52+
require.NoError(t, err)
53+
54+
reader, err := age.Decrypt(out, recordingIdentity)
55+
require.NoError(t, err)
56+
plaintext, err := io.ReadAll(reader)
57+
require.NoError(t, err)
58+
59+
require.Equal(t, msg, plaintext)
60+
61+
// running the same test with the raw recipient should fail because the
62+
// the extra stanza added by RecordingRecipient won't be present and
63+
// the private key won't be found
64+
out.Reset()
65+
writer, err = age.Encrypt(out, ident.Recipient())
66+
require.NoError(t, err)
67+
_, err = writer.Write(msg)
68+
require.NoError(t, err)
69+
err = writer.Close()
70+
require.NoError(t, err)
71+
_, err = age.Decrypt(out, recordingIdentity)
72+
require.Error(t, err)
73+
}
74+
75+
type fakeKeyStore struct {
76+
keys map[string]string
77+
}
78+
79+
func newFakeKeyStore() *fakeKeyStore {
80+
return &fakeKeyStore{
81+
keys: make(map[string]string),
82+
}
83+
}
84+
85+
func (f *fakeKeyStore) FindDecryptionKey(publicKeys ...[]byte) (*types.EncryptionKeyPair, error) {
86+
for _, pubKey := range publicKeys {
87+
key, ok := f.keys[string(pubKey)]
88+
if !ok {
89+
continue
90+
}
91+
92+
return &types.EncryptionKeyPair{
93+
PrivateKey: []byte(key),
94+
PublicKey: pubKey,
95+
}, nil
96+
}
97+
98+
return nil, errors.New("no accessible decryption key found")
99+
}
100+
101+
func (f *fakeKeyStore) generateIdentity() (*age.X25519Identity, error) {
102+
ident, err := age.GenerateX25519Identity()
103+
if err != nil {
104+
return nil, err
105+
}
106+
107+
f.keys[ident.Recipient().String()] = ident.String()
108+
return ident, nil
109+
}

0 commit comments

Comments
 (0)