Class: DatagroutConduit::Registration

Inherits:
Object
  • Object
show all
Defined in:
lib/datagrout_conduit/registration.rb

Overview

Substrate identity registration with the DataGrout CA.

Handles the issuance flow — turning a freshly-generated keypair into a DG-CA-signed Identity that DataGrout will accept for mTLS.

Flow

  1. Generate an ECDSA P-256 keypair with Registration.generate_keypair. The private key never leaves the client.

  2. Send the *public key* to the DataGrout CA via Registration.register_identity (authenticated with a bearer token — user access token or API key).

  3. Persist the returned identity to ~/.conduit/ via Registration.save_identity for auto-discovery by future sessions.

  4. On renewal (cert near expiry), call Registration.rotate_identity which presents the existing client certificate over mTLS — no API key needed.

Constant Summary collapse

DG_CA_URL =
"https://ca.datagrout.ai/ca.pem"
DG_SUBSTRATE_ENDPOINT =
"https://app.datagrout.ai/api/v1/substrate/identity"

Class Method Summary collapse

Class Method Details

.default_identity_dirString?

Returns ~/.conduit/ as the canonical identity directory.

Returns:

  • (String, nil)


209
210
211
212
# File 'lib/datagrout_conduit/registration.rb', line 209

def self.default_identity_dir
  home = ENV["HOME"] || ENV["USERPROFILE"]
  home ? File.join(home, ".conduit") : nil
end

.fetch_ca_cert(ca_url: DG_CA_URL) ⇒ String

Fetch the DataGrout CA certificate from ca.datagrout.ai.

Uses the system trust store for TLS (not the DG CA itself), so there is no circularity.

Parameters:

  • ca_url (String) (defaults to: DG_CA_URL)

    URL to fetch the CA cert from

Returns:

  • (String)

    PEM-encoded CA certificate



177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/datagrout_conduit/registration.rb', line 177

def self.fetch_ca_cert(ca_url: DG_CA_URL)
  response = Faraday.get(ca_url)

  unless response.success?
    raise ConnectionError, "Failed to fetch CA cert (HTTP #{response.status})"
  end

  pem = response.body
  unless pem.include?("-----BEGIN CERTIFICATE-----")
    raise ConnectionError, "Response from #{ca_url} does not look like a PEM certificate"
  end

  pem
end

.generate_keypairArray(String, String)

Generate an ECDSA P-256 keypair.

Returns:

  • (Array(String, String))

    [private_key_pem, public_key_pem]



32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/datagrout_conduit/registration.rb', line 32

def self.generate_keypair
  key = OpenSSL::PKey::EC.generate("prime256v1")
  private_pem = key.to_pem

  public_pem = if key.respond_to?(:public_to_pem)
                 key.public_to_pem
               else
                 pub = OpenSSL::PKey::EC.new(key.group)
                 pub.public_key = key.public_key
                 pub.to_pem
               end

  [private_pem, public_pem]
end

.refresh_ca_cert(dir, ca_url: DG_CA_URL) ⇒ String

Refresh CA cert in the given directory.

Parameters:

  • dir (String)

    directory to write ca.pem into

  • ca_url (String) (defaults to: DG_CA_URL)

    URL to fetch the CA cert from

Returns:

  • (String)

    path to the written ca.pem file



197
198
199
200
201
202
203
204
# File 'lib/datagrout_conduit/registration.rb', line 197

def self.refresh_ca_cert(dir, ca_url: DG_CA_URL)
  ca_pem = fetch_ca_cert(ca_url: ca_url)
  FileUtils.mkdir_p(dir)
  ca_path = File.join(dir, "ca.pem")
  File.write(ca_path, ca_pem)
  File.chmod(0o600, ca_path)
  ca_path
end

.register_identity(public_key_pem, auth_token:, name: "conduit-client", substrate_endpoint: DG_SUBSTRATE_ENDPOINT) ⇒ RegistrationResponse

Register identity with the DataGrout CA.

Sends only the public key. The private key never leaves the client. Authenticated with a bearer token (user access token or API key).

Parameters:

  • public_key_pem (String)

    PEM-encoded public key

  • auth_token (String)

    bearer token for authentication

  • name (String) (defaults to: "conduit-client")

    human-readable label for the substrate instance

  • substrate_endpoint (String) (defaults to: DG_SUBSTRATE_ENDPOINT)

    registration endpoint URL

Returns:



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/datagrout_conduit/registration.rb', line 57

def self.register_identity(public_key_pem, auth_token:, name: "conduit-client",
                           substrate_endpoint: DG_SUBSTRATE_ENDPOINT)
  conn = Faraday.new(url: substrate_endpoint) do |f|
    f.request :json
    f.response :json, content_type: /\bjson$/
    f.adapter Faraday.default_adapter
  end

  response = conn.post do |req|
    req.url "register"
    req.headers["Authorization"] = "Bearer #{auth_token}"
    req.headers["Content-Type"] = "application/json"
    req.body = JSON.generate(
      name: name,
      public_key_pem: public_key_pem
    )
  end

  unless response.success?
    raise AuthError, "Registration failed (HTTP #{response.status}): #{response.body}"
  end

  body = response.body
  body = JSON.parse(body) if body.is_a?(String)

  RegistrationResponse.new(
    id: body["id"],
    cert_pem: body["cert_pem"],
    ca_cert_pem: body["ca_cert_pem"],
    fingerprint: body["fingerprint"],
    name: body["name"],
    registered_at: body["registered_at"],
    valid_until: body["valid_until"]
  )
end

.rotate_identity(identity, new_public_key_pem, name: "conduit-client", substrate_endpoint: DG_SUBSTRATE_ENDPOINT) ⇒ RegistrationResponse

Rotate identity using existing mTLS cert.

Generates a new public key, sends it to the /rotate endpoint authenticated by the current cert over mTLS (no API key needed), and returns a fresh DG-CA-signed certificate.

Parameters:

  • identity (Identity)

    current mTLS identity for authentication

  • new_public_key_pem (String)

    PEM-encoded new public key

  • name (String) (defaults to: "conduit-client")

    human-readable label

  • substrate_endpoint (String) (defaults to: DG_SUBSTRATE_ENDPOINT)

    registration endpoint URL

Returns:



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/datagrout_conduit/registration.rb', line 104

def self.rotate_identity(identity, new_public_key_pem, name: "conduit-client",
                         substrate_endpoint: DG_SUBSTRATE_ENDPOINT)
  conn = Faraday.new(url: substrate_endpoint) do |f|
    f.request :json
    f.response :json, content_type: /\bjson$/
    f.adapter Faraday.default_adapter
    identity.configure_ssl(f.ssl)
  end

  response = conn.post do |req|
    req.url "rotate"
    req.headers["Content-Type"] = "application/json"
    req.body = JSON.generate(
      name: name,
      public_key_pem: new_public_key_pem
    )
  end

  unless response.success?
    raise ConnectionError, "Rotation failed (HTTP #{response.status}): #{response.body}"
  end

  body = response.body
  body = JSON.parse(body) if body.is_a?(String)

  RegistrationResponse.new(
    id: body["id"],
    cert_pem: body["cert_pem"],
    ca_cert_pem: body["ca_cert_pem"],
    fingerprint: body["fingerprint"],
    name: body["name"],
    registered_at: body["registered_at"],
    valid_until: body["valid_until"]
  )
end

.save_identity(cert_pem, key_pem, dir, ca_pem: nil) ⇒ Hash

Save identity files to a directory with secure permissions (0600).

Parameters:

  • cert_pem (String)

    DG-signed certificate PEM

  • key_pem (String)

    private key PEM

  • dir (String)

    directory path

  • ca_pem (String, nil) (defaults to: nil)

    CA certificate PEM

Returns:

  • (Hash)

    paths to written files (:cert, :key, :ca)



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/datagrout_conduit/registration.rb', line 147

def self.save_identity(cert_pem, key_pem, dir, ca_pem: nil)
  FileUtils.mkdir_p(dir)

  cert_path = File.join(dir, "identity.pem")
  key_path = File.join(dir, "identity_key.pem")

  File.write(cert_path, cert_pem)
  File.write(key_path, key_pem)
  File.chmod(0o600, cert_path)
  File.chmod(0o600, key_path)

  paths = { cert: cert_path, key: key_path }

  if ca_pem
    ca_path = File.join(dir, "ca.pem")
    File.write(ca_path, ca_pem)
    File.chmod(0o600, ca_path)
    paths[:ca] = ca_path
  end

  paths
end