diff --git a/lib/subca/dn.go b/lib/subca/dn.go
new file mode 100644
index 00000000000..c362f225335
--- /dev/null
+++ b/lib/subca/dn.go
@@ -0,0 +1,102 @@
+// Teleport
+// Copyright (C) 2026 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package subca
+
+import (
+ "crypto/x509/pkix"
+
+ "github.com/gravitational/trace"
+
+ subcav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/subca/v1"
+)
+
+// RDNSequenceToDistinguishedNameProto converts a pkix.Name to a
+// subcav1.DistinguishedName proto.
+//
+// All ATV values must be strings. Always returns non-nil on success.
+func RDNSequenceToDistinguishedNameProto(rdns pkix.RDNSequence) (*subcav1.DistinguishedName, error) {
+ if len(rdns) == 0 {
+ return &subcav1.DistinguishedName{}, nil
+ }
+
+ // Usually it's one item per nameset. OK if not.
+ estimatedLen := len(rdns)
+
+ dn := &subcav1.DistinguishedName{
+ Names: make([]*subcav1.AttributeTypeAndValue, 0, estimatedLen),
+ }
+ for i, nameSet := range rdns {
+ for j, atv := range nameSet {
+ oid := make([]int32, len(atv.Type))
+ for i, x := range atv.Type {
+ // OID components are guaranteed to fit in a 32 bit integer as checked
+ // by encoding/asn1.parseBase128Int.
+ // https://cs.opensource.google/go/go/+/refs/tags/go1.26.2:src/encoding/asn1/asn1.go;l=323
+ oid[i] = int32(x)
+ }
+ val, ok := atv.Value.(string)
+ if !ok {
+ return nil, trace.BadParameter(
+ "rdns[%d][%d]: ATV value is not a string: %q=%v (%T)",
+ i, j,
+ atv.Type, atv.Value, atv.Value,
+ )
+ }
+ dn.Names = append(dn.Names, &subcav1.AttributeTypeAndValue{
+ Oid: oid,
+ Value: &val,
+ })
+ }
+ }
+ return dn, nil
+}
+
+// DistinguishedNameProtoToRDNSequence converts a subcav1.DistinguishedName
+// proto to an RDNSequence.
+//
+// DistinguishedName protos are considered user-input, therefore this method
+// applies more strict validation than its sibling
+// [RDNSequenceToDistinguishedNameProto].
+//
+// A nil or empty DistinguishedName is considered invalid.
+func DistinguishedNameProtoToRDNSequence(dn *subcav1.DistinguishedName) (pkix.RDNSequence, error) {
+ if dn == nil || len(dn.Names) == 0 {
+ return nil, trace.BadParameter("empty distinguished name")
+ }
+
+ rdns := make(pkix.RDNSequence, 0, len(dn.Names))
+ for i, atv := range dn.Names {
+ switch {
+ case len(atv.GetOid()) == 0:
+ return nil, trace.BadParameter("names[%d]: empty OID", i)
+ case atv.Value == nil:
+ return nil, trace.BadParameter("names[%d]: nil Value", i)
+ }
+
+ oid := make([]int, len(atv.Oid))
+ for j, x := range atv.Oid {
+ oid[j] = int(x)
+ }
+ rdns = append(rdns, pkix.RelativeDistinguishedNameSET{
+ pkix.AttributeTypeAndValue{
+ Type: oid,
+ Value: *atv.Value,
+ },
+ })
+ }
+ return rdns, nil
+}
diff --git a/lib/subca/dn_test.go b/lib/subca/dn_test.go
new file mode 100644
index 00000000000..7bd7bdf2bdd
--- /dev/null
+++ b/lib/subca/dn_test.go
@@ -0,0 +1,138 @@
+// Teleport
+// Copyright (C) 2026 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package subca_test
+
+import (
+ "crypto/x509/pkix"
+ "encoding/asn1"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/testing/protocmp"
+
+ subcav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/subca/v1"
+ "github.com/gravitational/teleport/lib/subca"
+)
+
+func TestDistinguishedName_roundtrip(t *testing.T) {
+ t.Parallel()
+
+ want := (pkix.Name{
+ Organization: []string{"Llama"},
+ CommonName: "Llamo",
+ ExtraNames: []pkix.AttributeTypeAndValue{
+ {Type: asn1.ObjectIdentifier{1, 2, 3, 4}, Value: "alpaca"},
+ },
+ }).ToRDNSequence()
+
+ dn, err := subca.RDNSequenceToDistinguishedNameProto(want)
+ require.NoError(t, err, "RDNSequenceToDistinguishedNameProto errored")
+
+ got, err := subca.DistinguishedNameProtoToRDNSequence(dn)
+ require.NoError(t, err, "DistinguishedNameProtoToRDNSequence errored")
+ assert.Equal(t, want, got, "DistinguishedName roundtrip mismatch")
+}
+
+func TestRDNSequenceToDistinguishedNameProto(t *testing.T) {
+ t.Parallel()
+
+ // Success tested by TestDistinguishedName_roundtrip.
+
+ t.Run("nil", func(t *testing.T) {
+ t.Parallel()
+ got, err := subca.RDNSequenceToDistinguishedNameProto(nil)
+ require.NoError(t, err)
+ want := &subcav1.DistinguishedName{}
+ if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
+ t.Errorf("DistinguishedName mismatch (-want +got)\n%s", diff)
+ }
+ })
+
+ t.Run("non-string value", func(t *testing.T) {
+ t.Parallel()
+
+ rdns := (pkix.Name{
+ ExtraNames: []pkix.AttributeTypeAndValue{
+ {Type: asn1.ObjectIdentifier{1, 2, 3, 4}, Value: "ok"}, // OK
+ {Type: asn1.ObjectIdentifier{1, 2, 3, 5}, Value: 42}, // NOK
+ },
+ }).ToRDNSequence()
+
+ _, err := subca.RDNSequenceToDistinguishedNameProto(rdns)
+ assert.ErrorContains(t, err, "not a string", "error mismatch")
+ })
+}
+
+func TestDistinguishedNameProtoToRDNSequence(t *testing.T) {
+ t.Parallel()
+
+ // Success tested by TestDistinguishedName_roundtrip.
+
+ exampleValue := "alpaca"
+
+ tests := []struct {
+ name string
+ dn *subcav1.DistinguishedName
+ wantErr string
+ }{
+ {
+ name: "nil",
+ wantErr: "empty distinguished name",
+ },
+ {
+ name: "empty",
+ dn: &subcav1.DistinguishedName{},
+ wantErr: "empty distinguished name",
+ },
+ {
+ name: "ATV nil",
+ dn: &subcav1.DistinguishedName{
+ Names: []*subcav1.AttributeTypeAndValue{
+ nil,
+ },
+ },
+ wantErr: "empty OID",
+ },
+ {
+ name: "ATV.OID empty",
+ dn: &subcav1.DistinguishedName{
+ Names: []*subcav1.AttributeTypeAndValue{
+ {Oid: []int32{}, Value: &exampleValue},
+ },
+ },
+ wantErr: "empty OID",
+ },
+ {
+ name: "ATV.Value nil",
+ dn: &subcav1.DistinguishedName{
+ Names: []*subcav1.AttributeTypeAndValue{
+ {Oid: []int32{1, 2, 3, 4}},
+ },
+ },
+ wantErr: "nil Value",
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ t.Parallel()
+ _, err := subca.DistinguishedNameProtoToRDNSequence(test.dn)
+ assert.ErrorContains(t, err, test.wantErr, "error mismatch")
+ })
+ }
+}