Skip to content

Commit e2f11a4

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 eb4376b commit e2f11a4

File tree

2 files changed

+230
-0
lines changed

2 files changed

+230
-0
lines changed

lib/auth/recordingencryption/age.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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+
"context"
21+
22+
"filippo.io/age"
23+
"github.com/gravitational/trace"
24+
25+
"github.com/gravitational/teleport/api/types"
26+
)
27+
28+
// X25519Stanza is the default stanza type used by age.
29+
const X25519Stanza = "X25519"
30+
31+
// RecordingStanza is the type used for the identifying stanza added by RecordingRecipient.
32+
const RecordingStanza = "Recording-X25519"
33+
34+
// DecryptionKeyFinder returns an EncryptionKeyPair related to at least one of the given public keys to be used
35+
// for file key unwrapping.
36+
type DecryptionKeyFinder interface {
37+
FindDecryptionKey(ctx context.Context, publicKeys ...[]byte) (*types.EncryptionKeyPair, error)
38+
}
39+
40+
// RecordingIdentity removes public keys from stanzas and passes the unwrap call to the default
41+
// age.X25519Identity.
42+
type RecordingIdentity struct {
43+
ctx context.Context
44+
keyFinder DecryptionKeyFinder
45+
}
46+
47+
// NewRecordingIdentity returns a RecordingIdentity that will use the given DecryptionKeyFinder in order to facilitate
48+
// file key unwrapping.
49+
func NewRecordingIdentity(ctx context.Context, keyFinder DecryptionKeyFinder) *RecordingIdentity {
50+
return &RecordingIdentity{
51+
ctx: ctx,
52+
keyFinder: keyFinder,
53+
}
54+
}
55+
56+
// Unwrap uses the additional stanzas added by RecordingRecipient.Wrap in order to find a matching X25519 identity.
57+
func (i *RecordingIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
58+
var publicKeys [][]byte
59+
for _, stanza := range stanzas {
60+
if stanza.Type != RecordingStanza {
61+
continue
62+
}
63+
64+
if len(stanza.Args) != 1 {
65+
continue
66+
}
67+
68+
publicKeys = append(publicKeys, []byte(stanza.Args[0]))
69+
}
70+
71+
pair, err := i.keyFinder.FindDecryptionKey(i.ctx, publicKeys...)
72+
if err != nil {
73+
return nil, trace.Wrap(err)
74+
}
75+
76+
identity, err := age.ParseX25519Identity(string(pair.PrivateKey))
77+
if err != nil {
78+
return nil, trace.Wrap(err)
79+
}
80+
81+
return identity.Unwrap(stanzas)
82+
}
83+
84+
// RecordingRecipient adds the public key to the stanzas generated by the default age.X25519Recipient
85+
type RecordingRecipient struct {
86+
*age.X25519Recipient
87+
}
88+
89+
// ParseRecordingRecipient parses an Bech32 encoded age X25519 public key into a RecordingRecipient.
90+
func ParseRecordingRecipient(s string) (*RecordingRecipient, error) {
91+
recipient, err := age.ParseX25519Recipient(s)
92+
if err != nil {
93+
return nil, trace.Wrap(err)
94+
}
95+
96+
return &RecordingRecipient{X25519Recipient: recipient}, nil
97+
}
98+
99+
// Wrap a fileKey using the wrapped X2519Recipient. An additional stanza containing the bech32 encoded X25519
100+
// public key will be created to enable lookups during Unwrap.
101+
func (r *RecordingRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
102+
stanzas, err := r.X25519Recipient.Wrap(fileKey)
103+
if err != nil {
104+
return nil, trace.Wrap(err)
105+
}
106+
107+
// a new stanza has to be added because modifying the original stanza and returning it to "normal" during
108+
// Unwrap fails due to MAC errors
109+
for _, stanza := range stanzas {
110+
if stanza.Type == X25519Stanza {
111+
stanzas = append(stanzas, &age.Stanza{
112+
Type: RecordingStanza,
113+
Args: []string{r.String()},
114+
})
115+
}
116+
}
117+
118+
return stanzas, nil
119+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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+
"context"
22+
"errors"
23+
"io"
24+
"testing"
25+
26+
"filippo.io/age"
27+
"github.com/stretchr/testify/require"
28+
29+
"github.com/gravitational/teleport/api/types"
30+
"github.com/gravitational/teleport/lib/auth/recordingencryption"
31+
)
32+
33+
func TestRecordingAgePlugin(t *testing.T) {
34+
ctx := t.Context()
35+
keyFinder := newFakeKeyFinder()
36+
recordingIdentity := recordingencryption.NewRecordingIdentity(ctx, keyFinder)
37+
38+
ident, err := keyFinder.generateIdentity()
39+
require.NoError(t, err)
40+
41+
recipient, err := recordingencryption.ParseRecordingRecipient(ident.Recipient().String())
42+
require.NoError(t, err)
43+
44+
out := bytes.NewBuffer(nil)
45+
writer, err := age.Encrypt(out, recipient)
46+
require.NoError(t, err)
47+
48+
msg := []byte("testing age plugin for session recordings")
49+
_, err = writer.Write(msg)
50+
require.NoError(t, err)
51+
52+
// writer must be closed to ensure data is flushed
53+
err = writer.Close()
54+
require.NoError(t, err)
55+
56+
reader, err := age.Decrypt(out, recordingIdentity)
57+
require.NoError(t, err)
58+
plaintext, err := io.ReadAll(reader)
59+
require.NoError(t, err)
60+
61+
require.Equal(t, msg, plaintext)
62+
63+
// running the same test with the raw recipient should fail because the
64+
// the extra stanza added by RecordingRecipient won't be present and
65+
// the private key won't be found
66+
out.Reset()
67+
writer, err = age.Encrypt(out, ident.Recipient())
68+
require.NoError(t, err)
69+
_, err = writer.Write(msg)
70+
require.NoError(t, err)
71+
err = writer.Close()
72+
require.NoError(t, err)
73+
_, err = age.Decrypt(out, recordingIdentity)
74+
require.Error(t, err)
75+
}
76+
77+
type fakeKeyFinder struct {
78+
keys map[string]string
79+
}
80+
81+
func newFakeKeyFinder() *fakeKeyFinder {
82+
return &fakeKeyFinder{
83+
keys: make(map[string]string),
84+
}
85+
}
86+
87+
func (f *fakeKeyFinder) FindDecryptionKey(ctx context.Context, publicKeys ...[]byte) (*types.EncryptionKeyPair, error) {
88+
for _, pubKey := range publicKeys {
89+
key, ok := f.keys[string(pubKey)]
90+
if !ok {
91+
continue
92+
}
93+
94+
return &types.EncryptionKeyPair{
95+
PrivateKey: []byte(key),
96+
PublicKey: pubKey,
97+
}, nil
98+
}
99+
100+
return nil, errors.New("no accessible decryption key found")
101+
}
102+
103+
func (f *fakeKeyFinder) generateIdentity() (*age.X25519Identity, error) {
104+
ident, err := age.GenerateX25519Identity()
105+
if err != nil {
106+
return nil, err
107+
}
108+
109+
f.keys[ident.Recipient().String()] = ident.String()
110+
return ident, nil
111+
}

0 commit comments

Comments
 (0)