From a2a86d3bb612c7c9a35bd80aee82a2144a69c9af Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 8 Sep 2026 05:57:45 +0800 Subject: [PATCH] feat(identity): secure login connection secrets --- .../V66__login_connection_secret_versions.sql | 72 +++ .../LoginConnectionSecretMigrationTest.java | 249 ++++++++++ .../LoginConnectionSecretPersistenceTest.java | 430 ++++++++++++++++++ .../JpaLoginConnectionRepositoryAdapter.java | 9 + .../control/LoginConnectionRepository.java | 3 + .../LoginConnectionRevisionSnapshot.java | 17 +- .../LoginConnectionSpringDataRepository.java | 15 + .../StoredLoginConnectionRevision.java | 18 +- .../secret/AesGcmSecretEnvelopeCipher.java | 130 ++++++ .../secret/EncryptedSecretEnvelope.java | 60 +++ .../secret/InMemorySecretEnvelopeKeyring.java | 70 +++ ...oginConnectionSecretRepositoryAdapter.java | 76 ++++ .../LoginConnectionSecretRepository.java | 23 + .../secret/LoginConnectionSecretService.java | 207 +++++++++ ...nConnectionSecretSpringDataRepository.java | 42 ++ .../secret/LoginConnectionSecretStatus.java | 8 + .../secret/LoginConnectionSecretVersion.java | 246 ++++++++++ .../secret/SecretBindingContext.java | 44 ++ .../secret/SecretConfigurationSummary.java | 42 ++ .../secret/SecretEnvelopeCipher.java | 9 + .../connection/secret/SecretEnvelopeKey.java | 53 +++ .../secret/SecretEnvelopeKeyring.java | 11 + .../connection/secret/SecretMaterial.java | 49 ++ .../secret/SecretMaterialResolver.java | 13 + .../SecretMaterialUnavailableException.java | 9 + .../auth/connection/secret/SecretPurpose.java | 21 + .../connection/secret/SecretReference.java | 36 ++ .../auth/connection/secret/package-info.java | 2 + ...ConnectionRevisionSecretReferenceTest.java | 69 +++ .../AesGcmSecretEnvelopeCipherTest.java | 160 +++++++ .../SecretConfigurationSummaryTest.java | 26 ++ 31 files changed, 2213 insertions(+), 6 deletions(-) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V66__login_connection_secret_versions.sql create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/db/LoginConnectionSecretMigrationTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/LoginConnectionSecretPersistenceTest.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/AesGcmSecretEnvelopeCipher.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/EncryptedSecretEnvelope.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/InMemorySecretEnvelopeKeyring.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/JpaLoginConnectionSecretRepositoryAdapter.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretRepository.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretSpringDataRepository.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretStatus.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretVersion.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretBindingContext.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretConfigurationSummary.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretEnvelopeCipher.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretEnvelopeKey.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretEnvelopeKeyring.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretMaterial.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretMaterialResolver.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretMaterialUnavailableException.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretPurpose.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretReference.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/package-info.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/control/StoredLoginConnectionRevisionSecretReferenceTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/secret/AesGcmSecretEnvelopeCipherTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/secret/SecretConfigurationSummaryTest.java diff --git a/server/skillhub-app/src/main/resources/db/migration/V66__login_connection_secret_versions.sql b/server/skillhub-app/src/main/resources/db/migration/V66__login_connection_secret_versions.sql new file mode 100644 index 00000000..0a8af6e1 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V66__login_connection_secret_versions.sql @@ -0,0 +1,72 @@ +-- Encrypted Login Connection Secret versions. Cleartext never enters this schema. + +CREATE TABLE login_connection_secret_version ( + id VARCHAR(64) PRIMARY KEY, + organization_id VARCHAR(64) REFERENCES organization(id), + scope_key VARCHAR(64) GENERATED ALWAYS AS ( + COALESCE(organization_id, '@platform') + ) STORED, + connection_id VARCHAR(64) NOT NULL, + purpose VARCHAR(128) NOT NULL, + binding_version BIGINT NOT NULL, + algorithm VARCHAR(32) NOT NULL, + key_id VARCHAR(128) NOT NULL, + nonce BYTEA NOT NULL, + ciphertext BYTEA NOT NULL, + status VARCHAR(32) NOT NULL, + valid_until TIMESTAMPTZ, + created_by VARCHAR(128) NOT NULL REFERENCES user_account(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + revoked_by VARCHAR(128) REFERENCES user_account(id), + revoked_at TIMESTAMPTZ, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT fk_login_connection_secret_scope + FOREIGN KEY (connection_id, scope_key) + REFERENCES login_connection(id, scope_key), + CONSTRAINT uk_login_connection_secret_binding + UNIQUE (connection_id, binding_version), + CONSTRAINT ck_login_connection_secret_purpose CHECK ( + purpose ~ '^[a-z][a-z0-9._-]{2,127}$' + ), + CONSTRAINT ck_login_connection_secret_binding_version CHECK (binding_version > 0), + CONSTRAINT ck_login_connection_secret_algorithm CHECK (algorithm = 'AES-256-GCM'), + CONSTRAINT ck_login_connection_secret_key_id CHECK ( + key_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$' + ), + CONSTRAINT ck_login_connection_secret_nonce CHECK (octet_length(nonce) = 12), + CONSTRAINT ck_login_connection_secret_ciphertext CHECK (octet_length(ciphertext) > 16), + CONSTRAINT ck_login_connection_secret_status CHECK ( + status IN ('CURRENT', 'RETIRING', 'REVOKED') + ), + CONSTRAINT ck_login_connection_secret_lifecycle CHECK ( + (status = 'CURRENT' + AND valid_until IS NULL AND revoked_by IS NULL AND revoked_at IS NULL) + OR (status = 'RETIRING' + AND valid_until IS NOT NULL AND revoked_by IS NULL AND revoked_at IS NULL) + OR (status = 'REVOKED' AND revoked_by IS NOT NULL AND revoked_at IS NOT NULL) + ), + CONSTRAINT ck_login_connection_secret_version CHECK (version >= 0) +); + +CREATE UNIQUE INDEX uk_login_connection_secret_current + ON login_connection_secret_version (connection_id, purpose) + WHERE status = 'CURRENT'; + +CREATE UNIQUE INDEX uk_login_connection_secret_retiring + ON login_connection_secret_version (connection_id, purpose) + WHERE status = 'RETIRING'; + +CREATE INDEX idx_login_connection_secret_tenant_connection + ON login_connection_secret_version ( + organization_id, connection_id, purpose, binding_version DESC + ); + +CREATE INDEX idx_login_connection_secret_retiring_expiry + ON login_connection_secret_version (valid_until, connection_id, purpose) + WHERE status = 'RETIRING'; + +ALTER TABLE login_connection_revision + ADD CONSTRAINT fk_login_connection_revision_secret_binding + FOREIGN KEY (connection_id, secret_binding_version) + REFERENCES login_connection_secret_version(connection_id, binding_version); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/LoginConnectionSecretMigrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/LoginConnectionSecretMigrationTest.java new file mode 100644 index 00000000..216542b3 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/LoginConnectionSecretMigrationTest.java @@ -0,0 +1,249 @@ +package com.iflytek.skillhub.db; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationVersion; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +class LoginConnectionSecretMigrationTest { + + @Container + private static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:16-alpine"); + + @BeforeEach + void resetDatabase() { + flyway(null).clean(); + } + + @Test + void addsEncryptedVersionedSecretStorageAndBindsRevisionsToExactVersions() + throws SQLException { + migrateAndSeedConnections(); + + Flyway current = flyway(MigrationVersion.fromVersion("66")); + current.migrate(); + + assertThat(current.info().current().getVersion().getVersion()).isEqualTo("66"); + assertThat(queryStrings(""" + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'login_connection_secret_version' + ORDER BY ordinal_position + """)).contains( + "organization_id", + "connection_id", + "purpose", + "binding_version", + "algorithm", + "key_id", + "nonce", + "ciphertext", + "status", + "valid_until" + ).doesNotContain("plaintext", "value", "client_secret"); + + execute(secretInsert("secret-a", "org-a", "connection-a", 1, "CURRENT")); + execute(""" + INSERT INTO login_connection_revision ( + id, connection_id, revision, adapter_contract_version, + config_schema_version, capabilities, typed_config, + secret_binding_version, created_by + ) VALUES ( + 'revision-a', 'connection-a', 1, '1.0', 1, + '["IDENTITY_ASSERTION"]', '{}', 1, 'creator' + ) + """); + + assertThat(queryLong(""" + SELECT COUNT(*) + FROM login_connection_revision + WHERE connection_id = 'connection-a' AND secret_binding_version = 1 + """)).isEqualTo(1); + assertThatThrownBy(() -> execute(""" + INSERT INTO login_connection_revision ( + id, connection_id, revision, adapter_contract_version, + config_schema_version, capabilities, typed_config, + secret_binding_version, created_by + ) VALUES ( + 'revision-missing-secret', 'connection-a', 2, '1.0', 1, + '["IDENTITY_ASSERTION"]', '{}', 99, 'creator' + ) + """)).isInstanceOf(SQLException.class); + + execute(""" + INSERT INTO login_connection ( + id, public_handle, scope_type, organization_id, system_key, + display_name, status, adapter_key + ) VALUES ( + 'platform-github', 'platform-github-handle', 'PLATFORM', NULL, 'github', + 'GitHub', 'DRAFT', 'oauth' + ) + """); + execute(""" + INSERT INTO login_connection_secret_version ( + id, organization_id, connection_id, purpose, binding_version, + algorithm, key_id, nonce, ciphertext, status, valid_until, created_by + ) VALUES ( + 'platform-secret', NULL, 'platform-github', 'login.client-secret', 1, + 'AES-256-GCM', 'key-2026-09', decode(repeat('01', 12), 'hex'), + decode(repeat('02', 32), 'hex'), 'CURRENT', NULL, 'creator' + ) + """); + assertThat(queryLong(""" + SELECT COUNT(*) + FROM login_connection_secret_version + WHERE connection_id = 'platform-github' AND scope_key = '@platform' + """)).isEqualTo(1); + } + + @Test + void rejectsCrossTenantSecretsAndMultipleCurrentOrRetiringVersions() throws SQLException { + migrateAndSeedConnections(); + flyway(MigrationVersion.fromVersion("66")).migrate(); + + assertThatThrownBy(() -> execute( + secretInsert("cross-org", "org-b", "connection-a", 1, "CURRENT") + )).isInstanceOf(SQLException.class); + assertThatThrownBy(() -> execute(""" + INSERT INTO login_connection_secret_version ( + id, organization_id, connection_id, purpose, binding_version, + algorithm, key_id, nonce, ciphertext, status, valid_until, created_by + ) VALUES ( + 'cross-platform', NULL, 'connection-a', 'login.client-secret', 1, + 'AES-256-GCM', 'key-2026-09', decode(repeat('01', 12), 'hex'), + decode(repeat('02', 32), 'hex'), 'CURRENT', NULL, 'creator' + ) + """)).isInstanceOf(SQLException.class); + + execute(secretInsert("current-a", "org-a", "connection-a", 1, "CURRENT")); + assertThatThrownBy(() -> execute( + secretInsert("current-b", "org-a", "connection-a", 2, "CURRENT") + )).isInstanceOf(SQLException.class); + + execute(""" + UPDATE login_connection_secret_version + SET status = 'RETIRING', valid_until = CURRENT_TIMESTAMP + INTERVAL '1 hour' + WHERE id = 'current-a' + """); + assertThatThrownBy(() -> execute( + secretInsert("retiring-b", "org-a", "connection-a", 2, "RETIRING") + )).isInstanceOf(SQLException.class); + } + + private void migrateAndSeedConnections() throws SQLException { + flyway(MigrationVersion.fromVersion("65")).migrate(); + execute(""" + INSERT INTO user_account (id, display_name, status) + VALUES ('creator', 'Creator', 'ACTIVE') + """); + execute(""" + INSERT INTO organization (id, slug, display_name, created_by) + VALUES + ('org-a', 'org-a', 'Organization A', 'creator'), + ('org-b', 'org-b', 'Organization B', 'creator') + """); + execute(connectionInsert("connection-a", "org-a")); + execute(connectionInsert("connection-b", "org-b")); + } + + private String connectionInsert(String id, String organizationId) { + return """ + INSERT INTO login_connection ( + id, public_handle, scope_type, organization_id, + display_name, status, adapter_key + ) VALUES ( + '%s', '%s-handle', 'ORGANIZATION', '%s', 'OIDC', 'DRAFT', 'oidc' + ) + """.formatted(id, id, organizationId); + } + + private String secretInsert( + String id, + String organizationId, + String connectionId, + long bindingVersion, + String status + ) { + String validUntil = status.equals("RETIRING") + ? "CURRENT_TIMESTAMP + INTERVAL '1 hour'" + : "NULL"; + return """ + INSERT INTO login_connection_secret_version ( + id, organization_id, connection_id, purpose, binding_version, + algorithm, key_id, nonce, ciphertext, status, valid_until, created_by + ) VALUES ( + '%s', '%s', '%s', 'login.client-secret', %d, + 'AES-256-GCM', 'key-2026-09', decode(repeat('01', 12), 'hex'), + decode(repeat('02', 32), 'hex'), '%s', %s, 'creator' + ) + """.formatted( + id, + organizationId, + connectionId, + bindingVersion, + status, + validUntil + ); + } + + private Flyway flyway(MigrationVersion target) { + var configuration = Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .cleanDisabled(false); + if (target != null) { + configuration.target(target); + } + return configuration.load(); + } + + private void execute(String sql) throws SQLException { + try (Connection connection = connection(); Statement statement = connection.createStatement()) { + statement.execute(sql); + } + } + + private long queryLong(String sql) throws SQLException { + try (Connection connection = connection(); + Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery(sql)) { + assertThat(result.next()).isTrue(); + return result.getLong(1); + } + } + + private List queryStrings(String sql) throws SQLException { + try (Connection connection = connection(); + Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery(sql)) { + var values = new ArrayList(); + while (result.next()) { + values.add(result.getString(1)); + } + return List.copyOf(values); + } + } + + private Connection connection() throws SQLException { + return DriverManager.getConnection( + POSTGRES.getJdbcUrl(), + POSTGRES.getUsername(), + POSTGRES.getPassword() + ); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/LoginConnectionSecretPersistenceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/LoginConnectionSecretPersistenceTest.java new file mode 100644 index 00000000..fea7b3ac --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/LoginConnectionSecretPersistenceTest.java @@ -0,0 +1,430 @@ +package com.iflytek.skillhub.repository; + +import static java.nio.charset.StandardCharsets.ISO_8859_1; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.auth.connection.control.JpaLoginConnectionRepositoryAdapter; +import com.iflytek.skillhub.auth.connection.control.LoginConnection; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionRepository; +import com.iflytek.skillhub.auth.connection.core.AdapterKey; +import com.iflytek.skillhub.auth.connection.secret.AesGcmSecretEnvelopeCipher; +import com.iflytek.skillhub.auth.connection.secret.InMemorySecretEnvelopeKeyring; +import com.iflytek.skillhub.auth.connection.secret.JpaLoginConnectionSecretRepositoryAdapter; +import com.iflytek.skillhub.auth.connection.secret.LoginConnectionSecretRepository; +import com.iflytek.skillhub.auth.connection.secret.LoginConnectionSecretService; +import com.iflytek.skillhub.auth.connection.secret.LoginConnectionSecretStatus; +import com.iflytek.skillhub.auth.connection.secret.SecretConfigurationSummary; +import com.iflytek.skillhub.auth.connection.secret.SecretEnvelopeCipher; +import com.iflytek.skillhub.auth.connection.secret.SecretEnvelopeKey; +import com.iflytek.skillhub.auth.connection.secret.SecretMaterial; +import com.iflytek.skillhub.auth.connection.secret.SecretMaterialUnavailableException; +import com.iflytek.skillhub.auth.connection.secret.SecretPurpose; +import com.iflytek.skillhub.auth.connection.secret.SecretReference; +import com.iflytek.skillhub.domain.organization.Organization; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.user.UserAccount; +import jakarta.persistence.EntityManager; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@DataJpaTest(properties = { + "spring.flyway.enabled=true", + "spring.jpa.hibernate.ddl-auto=validate" +}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +@Transactional(propagation = Propagation.NOT_SUPPORTED) +@Import({ + JpaLoginConnectionRepositoryAdapter.class, + JpaLoginConnectionSecretRepositoryAdapter.class, + LoginConnectionSecretService.class, + LoginConnectionSecretPersistenceTest.SecretCipherConfiguration.class +}) +class LoginConnectionSecretPersistenceTest { + + private static final Instant NOW = Instant.parse("2026-09-08T06:00:00Z"); + + @Container + private static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void configurePostgres(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add( + "spring.jpa.database-platform", + () -> "org.hibernate.dialect.PostgreSQLDialect" + ); + } + + @Autowired + private LoginConnectionRepository connectionRepository; + + @Autowired + private LoginConnectionSecretRepository secretRepository; + + @Autowired + private LoginConnectionSecretService secretService; + + @Autowired + private EntityManager entityManager; + + @Autowired + private PlatformTransactionManager transactionManager; + + private String organizationId; + private String actorId; + private LoginConnection connection; + + @BeforeEach + void setUp() { + String unique = UUID.randomUUID().toString(); + actorId = "secret-admin-" + unique; + new TransactionTemplate(transactionManager).executeWithoutResult(ignored -> { + UserAccount actor = new UserAccount( + actorId, + "Secret Admin", + unique + "@example.com", + null + ); + entityManager.persist(actor); + Organization organization = Organization.create( + "secret-" + unique, + "Secret Organization", + actor.getId(), + NOW + ); + entityManager.persist(organization); + organizationId = organization.getId(); + }); + connection = connectionRepository.save(LoginConnection.createOrganization( + organizationId, + "Corporate OIDC", + new AdapterKey("oidc"), + actorId, + NOW + )); + } + + @Test + void storesOnlyAuthenticatedCiphertextAndReturnsRedactedMetadata() { + SecretReference reference = rotate("client-secret-that-must-not-leak", Duration.ZERO, NOW); + + Object[] stored = new TransactionTemplate(transactionManager).execute(status -> + (Object[]) entityManager.createNativeQuery(""" + SELECT ciphertext, nonce, key_id + FROM login_connection_secret_version + WHERE connection_id = :connectionId AND binding_version = 1 + """) + .setParameter("connectionId", connection.getId()) + .getSingleResult() + ); + assertThat(new String((byte[]) stored[0], ISO_8859_1)) + .doesNotContain("client-secret-that-must-not-leak"); + assertThat((byte[]) stored[1]).hasSize(12); + assertThat(stored[2]).isEqualTo("test-master-key"); + + try (SecretMaterial resolved = secretService.resolve( + reference, + SecretPurpose.LOGIN_CLIENT_SECRET, + NOW.plusSeconds(1) + )) { + assertThat(read(resolved)).isEqualTo("client-secret-that-must-not-leak"); + } + + SecretConfigurationSummary summary = secretService.describe( + organizationId, + connection.getId(), + SecretPurpose.LOGIN_CLIENT_SECRET + ); + assertThat(summary.configured()).isTrue(); + assertThat(summary.updatedAt()).isEqualTo(NOW); + assertThat(summary.previousValidUntil()).isNull(); + assertThat(secretRepository.findAll( + organizationId, + connection.getId(), + SecretPurpose.LOGIN_CLIENT_SECRET + )).singleElement().satisfies(secret -> assertThat(secret.toString()) + .contains("envelope=") + .doesNotContain( + "client-secret-that-must-not-leak", + "test-master-key" + )); + } + + @Test + void rotationKeepsOnePinnedPreviousVersionForTheBoundedWindow() { + SecretReference first = rotate("first-secret", Duration.ZERO, NOW); + SecretReference second = rotate( + "second-secret", + Duration.ofHours(1), + NOW.plusSeconds(10) + ); + + assertThat(readResolved(first, NOW.plusSeconds(20))).isEqualTo("first-secret"); + assertThat(readResolved(second, NOW.plusSeconds(20))).isEqualTo("second-secret"); + + SecretReference third = rotate( + "third-secret", + Duration.ofMinutes(30), + NOW.plusSeconds(30) + ); + + assertUnavailable(() -> secretService.resolve( + first, + SecretPurpose.LOGIN_CLIENT_SECRET, + NOW.plusSeconds(31) + )); + assertThat(readResolved(second, NOW.plusSeconds(31))).isEqualTo("second-secret"); + assertThat(readResolved(third, NOW.plusSeconds(31))).isEqualTo("third-secret"); + assertUnavailable(() -> secretService.resolve( + second, + SecretPurpose.LOGIN_CLIENT_SECRET, + NOW.plusSeconds(30).plus(Duration.ofMinutes(30)) + )); + + assertThat(secretRepository.findAll( + organizationId, + connection.getId(), + SecretPurpose.LOGIN_CLIENT_SECRET + )).extracting(secret -> secret.getStatus()) + .containsExactly( + LoginConnectionSecretStatus.CURRENT, + LoginConnectionSecretStatus.RETIRING, + LoginConnectionSecretStatus.REVOKED + ); + assertThat(secretService.describe( + organizationId, + connection.getId(), + SecretPurpose.LOGIN_CLIENT_SECRET + ).previousValidUntil()).isEqualTo(NOW.plusSeconds(30).plus(Duration.ofMinutes(30))); + } + + @Test + void wrongTenantPurposeAndRevokedVersionAllFailWithTheSameSafeError() { + SecretReference current = rotate("revocable-secret", Duration.ZERO, NOW); + + assertUnavailable(() -> secretService.resolve( + new SecretReference( + "another-organization", + connection.getId(), + SecretPurpose.LOGIN_CLIENT_SECRET, + current.bindingVersion() + ), + SecretPurpose.LOGIN_CLIENT_SECRET, + NOW.plusSeconds(1) + )); + assertUnavailable(() -> secretService.resolve( + current, + new SecretPurpose("login.private-key"), + NOW.plusSeconds(1) + )); + + secretService.revoke( + organizationId, + connection.getId(), + SecretPurpose.LOGIN_CLIENT_SECRET, + current.bindingVersion(), + actorId, + NOW.plusSeconds(2) + ); + + assertUnavailable(() -> secretService.resolve( + current, + SecretPurpose.LOGIN_CLIENT_SECRET, + NOW.plusSeconds(3) + )); + assertThat(secretService.describe( + organizationId, + connection.getId(), + SecretPurpose.LOGIN_CLIENT_SECRET + )).isEqualTo(SecretConfigurationSummary.notConfigured()); + } + + @Test + void rejectsUnboundedRotationWindowBeforeWritingAnything() { + try (SecretMaterial material = SecretMaterial.copyOf("new-secret".getBytes(UTF_8))) { + assertThatThrownBy(() -> secretService.rotate( + organizationId, + connection.getId(), + SecretPurpose.LOGIN_CLIENT_SECRET, + material, + Duration.ofHours(25), + actorId, + NOW + )).isInstanceOf(DomainBadRequestException.class) + .hasMessage("error.loginConnection.secret.rotation.overlap.invalid"); + } + + assertThat(secretRepository.findAll( + organizationId, + connection.getId(), + SecretPurpose.LOGIN_CLIENT_SECRET + )).isEmpty(); + } + + @Test + void bindingVersionsStayConnectionWideAcrossDifferentPurposes() { + SecretReference clientSecret = rotate("client-secret", Duration.ZERO, NOW); + SecretPurpose privateKeyPurpose = new SecretPurpose("login.private-key"); + SecretReference privateKey; + try (SecretMaterial material = SecretMaterial.copyOf("private-key".getBytes(UTF_8))) { + privateKey = secretService.rotate( + organizationId, + connection.getId(), + privateKeyPurpose, + material, + Duration.ZERO, + actorId, + NOW.plusSeconds(1) + ); + } + + assertThat(clientSecret.bindingVersion()).isEqualTo(1); + assertThat(privateKey.bindingVersion()).isEqualTo(2); + try (SecretMaterial resolved = secretService.resolve( + privateKey, + privateKeyPurpose, + NOW.plusSeconds(2) + )) { + assertThat(read(resolved)).isEqualTo("private-key"); + } + } + + @Test + void concurrentRotationsAreSerializedByTheConnectionLock() throws Exception { + rotate("initial-secret", Duration.ZERO, NOW); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try (var executor = Executors.newFixedThreadPool(2)) { + var first = executor.submit(() -> { + ready.countDown(); + start.await(); + return rotate( + "concurrent-secret-a", + Duration.ofMinutes(10), + NOW.plusSeconds(1) + ); + }); + var second = executor.submit(() -> { + ready.countDown(); + start.await(); + return rotate( + "concurrent-secret-b", + Duration.ofMinutes(10), + NOW.plusSeconds(1) + ); + }); + + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + assertThat(Set.of( + first.get(20, TimeUnit.SECONDS).bindingVersion(), + second.get(20, TimeUnit.SECONDS).bindingVersion() + )).containsExactlyInAnyOrder(2L, 3L); + } + + assertThat(secretRepository.findAll( + organizationId, + connection.getId(), + SecretPurpose.LOGIN_CLIENT_SECRET + )).extracting(secret -> secret.getStatus()) + .containsExactly( + LoginConnectionSecretStatus.CURRENT, + LoginConnectionSecretStatus.RETIRING, + LoginConnectionSecretStatus.REVOKED + ); + } + + private SecretReference rotate(String value, Duration overlap, Instant occurredAt) { + try (SecretMaterial material = SecretMaterial.copyOf(value.getBytes(UTF_8))) { + return secretService.rotate( + organizationId, + connection.getId(), + SecretPurpose.LOGIN_CLIENT_SECRET, + material, + overlap, + actorId, + occurredAt + ); + } + } + + private String readResolved(SecretReference reference, Instant now) { + try (SecretMaterial resolved = secretService.resolve( + reference, + SecretPurpose.LOGIN_CLIENT_SECRET, + now + )) { + return read(resolved); + } + } + + private static String read(SecretMaterial material) { + return material.use(bytes -> new String(bytes, UTF_8)); + } + + private static void assertUnavailable(ThrowingOperation operation) { + assertThatThrownBy(operation::run) + .isInstanceOf(SecretMaterialUnavailableException.class) + .hasMessage("Secret material is unavailable") + .hasNoCause(); + } + + @TestConfiguration + static class SecretCipherConfiguration { + + @Bean(destroyMethod = "close") + InMemorySecretEnvelopeKeyring secretEnvelopeKeyring() { + byte[] bytes = new byte[32]; + Arrays.fill(bytes, (byte) 5); + SecretEnvelopeKey key = new SecretEnvelopeKey("test-master-key", bytes); + Arrays.fill(bytes, (byte) 0); + try (key) { + return new InMemorySecretEnvelopeKeyring(key); + } + } + + @Bean + SecretEnvelopeCipher secretEnvelopeCipher( + InMemorySecretEnvelopeKeyring keyring + ) { + return new AesGcmSecretEnvelopeCipher(keyring); + } + } + + @FunctionalInterface + private interface ThrowingOperation { + void run(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/JpaLoginConnectionRepositoryAdapter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/JpaLoginConnectionRepositoryAdapter.java index 899dd256..a1e7932f 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/JpaLoginConnectionRepositoryAdapter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/JpaLoginConnectionRepositoryAdapter.java @@ -25,6 +25,15 @@ public class JpaLoginConnectionRepositoryAdapter implements LoginConnectionRepos ); } + @Override + @Transactional + public Optional lockByOrganizationIdAndId(String organizationId, String id) { + return delegate.lockByOrganizationIdAndId( + requireText(organizationId, "organizationId"), + requireText(id, "id") + ); + } + @Override @Transactional(readOnly = true) public Optional findByPublicHandle(ConnectionHandle handle) { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionRepository.java index 1751d90d..37cf0a15 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionRepository.java @@ -8,6 +8,9 @@ public interface LoginConnectionRepository { Optional findByOrganizationIdAndId(String organizationId, String id); + /** Serializes high-risk control-plane mutations such as Secret rotation. */ + Optional lockByOrganizationIdAndId(String organizationId, String id); + /** Dedicated data-plane lookup; the random handle reveals no tenant management identifier. */ Optional findByPublicHandle(ConnectionHandle handle); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionRevisionSnapshot.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionRevisionSnapshot.java index 3a39b336..7542b107 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionRevisionSnapshot.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionRevisionSnapshot.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.auth.connection.control; import com.iflytek.skillhub.auth.connection.core.AdapterKey; import com.iflytek.skillhub.auth.connection.core.ConnectionHandle; +import com.iflytek.skillhub.auth.connection.secret.SecretReference; import java.util.Objects; import java.util.Optional; @@ -17,7 +18,7 @@ public record LoginConnectionRevisionSnapshot( int configSchemaVersion, String capabilitiesJson, String typedConfigJson, - Optional secretBindingVersion + Optional secretReference ) { public LoginConnectionRevisionSnapshot { @@ -36,7 +37,15 @@ public record LoginConnectionRevisionSnapshot( } capabilitiesJson = requireText(capabilitiesJson, "capabilitiesJson"); typedConfigJson = requireText(typedConfigJson, "typedConfigJson"); - secretBindingVersion = Objects.requireNonNull(secretBindingVersion, "secretBindingVersion"); + secretReference = Objects.requireNonNull(secretReference, "secretReference"); + if (secretReference.isPresent()) { + SecretReference reference = secretReference.get(); + String expectedScope = organizationId.orElse(SecretReference.PLATFORM_SCOPE_KEY); + if (!expectedScope.equals(reference.scopeKey()) + || !connectionId.equals(reference.connectionId())) { + throw new IllegalArgumentException("Secret reference scope is invalid"); + } + } } private static String requireText(String value, String field) { @@ -59,7 +68,7 @@ public record LoginConnectionRevisionSnapshot( + ", adapterKey=" + adapterKey.value() + ", adapterContractVersion=" + adapterContractVersion + ", configSchemaVersion=" + configSchemaVersion - + ", capabilitiesJson=, typedConfigJson=, secretBindingVersion=" - + (secretBindingVersion.isPresent() ? "present" : "none") + "]"; + + ", capabilitiesJson=, typedConfigJson=, secretReference=" + + (secretReference.isPresent() ? "present" : "none") + "]"; } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionSpringDataRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionSpringDataRepository.java index d774a8a8..60981018 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionSpringDataRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionSpringDataRepository.java @@ -1,12 +1,27 @@ package com.iflytek.skillhub.auth.connection.control; import java.util.Optional; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; /** Internal Spring Data delegate; callers use the tenant-scoped repository port. */ interface LoginConnectionSpringDataRepository extends JpaRepository { Optional findByOrganizationIdAndId(String organizationId, String id); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select connection + from LoginConnection connection + where connection.organizationId = :organizationId and connection.id = :id + """) + Optional lockByOrganizationIdAndId( + @Param("organizationId") String organizationId, + @Param("id") String id + ); + Optional findByPublicHandle(String publicHandle); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/StoredLoginConnectionRevision.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/StoredLoginConnectionRevision.java index 3de3e2b6..359e5b49 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/StoredLoginConnectionRevision.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/StoredLoginConnectionRevision.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.auth.connection.control; import com.iflytek.skillhub.auth.connection.core.ConnectionUnavailableException; +import com.iflytek.skillhub.auth.connection.secret.SecretPurpose; +import com.iflytek.skillhub.auth.connection.secret.SecretReference; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -84,7 +86,7 @@ public class StoredLoginConnectionRevision { this.configSchemaVersion = configSchemaVersion; this.capabilities = requireJsonShape(capabilities, '[', ']', "capabilities"); this.typedConfig = requireJsonShape(typedConfig, '{', '}', "typedConfig"); - if (secretBindingVersion != null && secretBindingVersion < 0) { + if (secretBindingVersion != null && secretBindingVersion < 1) { throw invalid("secretBindingVersion"); } this.secretBindingVersion = secretBindingVersion; @@ -132,10 +134,22 @@ public class StoredLoginConnectionRevision { configSchemaVersion, capabilities, typedConfig, - Optional.ofNullable(secretBindingVersion) + secretReference(connection) ); } + private Optional secretReference(LoginConnection connection) { + if (secretBindingVersion == null) { + return Optional.empty(); + } + return Optional.of(new SecretReference( + connection.getOrganizationId().orElse(SecretReference.PLATFORM_SCOPE_KEY), + connectionId, + SecretPurpose.LOGIN_CLIENT_SECRET, + secretBindingVersion + )); + } + private static String requireContractVersion(String value) { String normalized = requireText(value, "adapterContractVersion"); if (!CONTRACT_VERSION.matcher(normalized).matches()) { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/AesGcmSecretEnvelopeCipher.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/AesGcmSecretEnvelopeCipher.java new file mode 100644 index 00000000..0b8e5114 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/AesGcmSecretEnvelopeCipher.java @@ -0,0 +1,130 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Objects; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** AES-256-GCM local Secret envelope implementation with context-bound authenticated data. */ +public final class AesGcmSecretEnvelopeCipher implements SecretEnvelopeCipher { + + public static final String ALGORITHM = "AES-256-GCM"; + static final int NONCE_BYTES = 12; + static final int TAG_BYTES = 16; + private static final int TAG_BITS = TAG_BYTES * Byte.SIZE; + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + + private final SecretEnvelopeKeyring keyring; + private final SecureRandom secureRandom; + + public AesGcmSecretEnvelopeCipher(SecretEnvelopeKeyring keyring) { + this(keyring, new SecureRandom()); + } + + AesGcmSecretEnvelopeCipher(SecretEnvelopeKeyring keyring, SecureRandom secureRandom) { + this.keyring = Objects.requireNonNull(keyring, "keyring"); + this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom"); + } + + @Override + public EncryptedSecretEnvelope encrypt( + SecretBindingContext context, + SecretMaterial plaintext + ) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(plaintext, "plaintext"); + try (SecretEnvelopeKey key = keyring.activeKey()) { + byte[] nonce = new byte[NONCE_BYTES]; + secureRandom.nextBytes(nonce); + byte[] ciphertext = plaintext.use(bytes -> encrypt( + bytes, + key, + nonce, + context.authenticatedData(ALGORITHM, key.keyId()) + )); + return new EncryptedSecretEnvelope(ALGORITHM, key.keyId(), nonce, ciphertext); + } + } + + @Override + public SecretMaterial decrypt( + SecretBindingContext context, + EncryptedSecretEnvelope envelope + ) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(envelope, "envelope"); + if (!ALGORITHM.equals(envelope.algorithm())) { + throw new SecretMaterialUnavailableException(); + } + SecretEnvelopeKey key = keyring.findById(envelope.keyId()) + .orElseThrow(SecretMaterialUnavailableException::new); + try (key) { + byte[] plaintext = decrypt( + envelope.ciphertext(), + key, + envelope.nonce(), + context.authenticatedData(envelope.algorithm(), envelope.keyId()) + ); + try { + return SecretMaterial.copyOf(plaintext); + } finally { + Arrays.fill(plaintext, (byte) 0); + } + } catch (SecretMaterialUnavailableException exception) { + throw exception; + } catch (RuntimeException exception) { + throw new SecretMaterialUnavailableException(); + } + } + + private static byte[] encrypt( + byte[] plaintext, + SecretEnvelopeKey key, + byte[] nonce, + byte[] authenticatedData + ) { + try { + return crypt(Cipher.ENCRYPT_MODE, plaintext, key, nonce, authenticatedData); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("Unable to encrypt Secret material", exception); + } + } + + private static byte[] decrypt( + byte[] ciphertext, + SecretEnvelopeKey key, + byte[] nonce, + byte[] authenticatedData + ) { + try { + return crypt(Cipher.DECRYPT_MODE, ciphertext, key, nonce, authenticatedData); + } catch (GeneralSecurityException exception) { + throw new SecretMaterialUnavailableException(); + } + } + + private static byte[] crypt( + int mode, + byte[] input, + SecretEnvelopeKey key, + byte[] nonce, + byte[] authenticatedData + ) throws GeneralSecurityException { + byte[] keyBytes = key.copyKeyBytes(); + try { + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init( + mode, + new SecretKeySpec(keyBytes, "AES"), + new GCMParameterSpec(TAG_BITS, nonce) + ); + cipher.updateAAD(authenticatedData); + return cipher.doFinal(input); + } finally { + Arrays.fill(keyBytes, (byte) 0); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/EncryptedSecretEnvelope.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/EncryptedSecretEnvelope.java new file mode 100644 index 00000000..b3f4fc7a --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/EncryptedSecretEnvelope.java @@ -0,0 +1,60 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import java.util.Objects; + +/** Authenticated ciphertext persisted at rest; byte accessors always return defensive copies. */ +public final class EncryptedSecretEnvelope { + + private final String algorithm; + private final String keyId; + private final byte[] nonce; + private final byte[] ciphertext; + + public EncryptedSecretEnvelope( + String algorithm, + String keyId, + byte[] nonce, + byte[] ciphertext + ) { + this.algorithm = requireText(algorithm, "algorithm"); + this.keyId = requireText(keyId, "keyId"); + this.nonce = Objects.requireNonNull(nonce, "nonce").clone(); + this.ciphertext = Objects.requireNonNull(ciphertext, "ciphertext").clone(); + if (this.nonce.length != AesGcmSecretEnvelopeCipher.NONCE_BYTES) { + throw new IllegalArgumentException("Secret envelope nonce length is invalid"); + } + if (this.ciphertext.length <= AesGcmSecretEnvelopeCipher.TAG_BYTES) { + throw new IllegalArgumentException("Secret envelope ciphertext length is invalid"); + } + } + + public String algorithm() { + return algorithm; + } + + public String keyId() { + return keyId; + } + + public byte[] nonce() { + return nonce.clone(); + } + + public byte[] ciphertext() { + return ciphertext.clone(); + } + + private static String requireText(String value, String field) { + Objects.requireNonNull(value, field); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return normalized; + } + + @Override + public String toString() { + return "EncryptedSecretEnvelope[]"; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/InMemorySecretEnvelopeKeyring.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/InMemorySecretEnvelopeKeyring.java new file mode 100644 index 00000000..99ec8b17 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/InMemorySecretEnvelopeKeyring.java @@ -0,0 +1,70 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** Process-local keyring useful for composition and tests; callers receive disposable key copies. */ +public final class InMemorySecretEnvelopeKeyring implements SecretEnvelopeKeyring, AutoCloseable { + + private final String activeKeyId; + private final Map keys; + private boolean closed; + + public InMemorySecretEnvelopeKeyring(SecretEnvelopeKey activeKey, SecretEnvelopeKey... oldKeys) { + Objects.requireNonNull(activeKey, "activeKey"); + Objects.requireNonNull(oldKeys, "oldKeys"); + this.activeKeyId = activeKey.keyId(); + this.keys = new LinkedHashMap<>(); + putCopy(activeKey); + for (SecretEnvelopeKey oldKey : oldKeys) { + putCopy(Objects.requireNonNull(oldKey, "oldKey")); + } + } + + @Override + public synchronized SecretEnvelopeKey activeKey() { + return copy(activeKeyId).orElseThrow(() -> new IllegalStateException("Secret keyring is closed")); + } + + @Override + public synchronized Optional findById(String keyId) { + Objects.requireNonNull(keyId, "keyId"); + return copy(keyId); + } + + private Optional copy(String keyId) { + if (closed) { + return Optional.empty(); + } + byte[] bytes = keys.get(keyId); + return bytes == null + ? Optional.empty() + : Optional.of(new SecretEnvelopeKey(keyId, bytes)); + } + + private void putCopy(SecretEnvelopeKey key) { + byte[] copy = key.copyKeyBytes(); + byte[] previous = keys.putIfAbsent(key.keyId(), copy); + if (previous != null) { + Arrays.fill(copy, (byte) 0); + throw new IllegalArgumentException("Duplicate Secret envelope key id"); + } + } + + @Override + public synchronized void close() { + if (!closed) { + keys.values().forEach(bytes -> Arrays.fill(bytes, (byte) 0)); + keys.clear(); + closed = true; + } + } + + @Override + public String toString() { + return "InMemorySecretEnvelopeKeyring[]"; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/JpaLoginConnectionSecretRepositoryAdapter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/JpaLoginConnectionSecretRepositoryAdapter.java new file mode 100644 index 00000000..e6ce9293 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/JpaLoginConnectionSecretRepositoryAdapter.java @@ -0,0 +1,76 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** JPA adapter that exposes no unscoped lookup and no raw Secret material. */ +@Repository +public class JpaLoginConnectionSecretRepositoryAdapter + implements LoginConnectionSecretRepository { + + private final LoginConnectionSecretSpringDataRepository delegate; + + public JpaLoginConnectionSecretRepositoryAdapter( + LoginConnectionSecretSpringDataRepository delegate + ) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + } + + @Override + @Transactional + public List findAllForUpdate( + String scopeKey, + String connectionId + ) { + return delegate.findAllForUpdate( + requireText(scopeKey, "scopeKey"), + requireText(connectionId, "connectionId") + ); + } + + @Override + @Transactional(readOnly = true) + public List findAll( + String scopeKey, + String connectionId, + SecretPurpose purpose + ) { + return delegate.findAllByScopeKeyAndConnectionIdAndPurposeOrderByBindingVersionDesc( + requireText(scopeKey, "scopeKey"), + requireText(connectionId, "connectionId"), + Objects.requireNonNull(purpose, "purpose").value() + ); + } + + @Override + @Transactional(readOnly = true) + public Optional findByReference(SecretReference reference) { + SecretReference requiredReference = Objects.requireNonNull(reference, "reference"); + return delegate.findByScopeKeyAndConnectionIdAndPurposeAndBindingVersion( + requiredReference.scopeKey(), + requiredReference.connectionId(), + requiredReference.purpose().value(), + requiredReference.bindingVersion() + ); + } + + @Override + @Transactional + public LoginConnectionSecretVersion saveAndFlush( + LoginConnectionSecretVersion secretVersion + ) { + return delegate.saveAndFlush(Objects.requireNonNull(secretVersion, "secretVersion")); + } + + private static String requireText(String value, String field) { + Objects.requireNonNull(value, field); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return normalized; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretRepository.java new file mode 100644 index 00000000..ff2daaf3 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretRepository.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import java.util.List; +import java.util.Optional; + +/** Tenant-scoped Secret persistence port; raw envelope access stays inside the Secret package. */ +public interface LoginConnectionSecretRepository { + + List findAllForUpdate( + String scopeKey, + String connectionId + ); + + List findAll( + String scopeKey, + String connectionId, + SecretPurpose purpose + ); + + Optional findByReference(SecretReference reference); + + LoginConnectionSecretVersion saveAndFlush(LoginConnectionSecretVersion secretVersion); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretService.java new file mode 100644 index 00000000..38cf8c5b --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretService.java @@ -0,0 +1,207 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import com.iflytek.skillhub.auth.connection.control.LoginConnectionRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import org.springframework.transaction.annotation.Transactional; + +/** Transactional control plane and privacy-preserving data plane for Login Secrets. */ +public class LoginConnectionSecretService implements SecretMaterialResolver { + + public static final Duration MAX_ROTATION_OVERLAP = Duration.ofHours(24); + + private final LoginConnectionRepository connectionRepository; + private final LoginConnectionSecretRepository secretRepository; + private final SecretEnvelopeCipher envelopeCipher; + + public LoginConnectionSecretService( + LoginConnectionRepository connectionRepository, + LoginConnectionSecretRepository secretRepository, + SecretEnvelopeCipher envelopeCipher + ) { + this.connectionRepository = Objects.requireNonNull( + connectionRepository, + "connectionRepository" + ); + this.secretRepository = Objects.requireNonNull(secretRepository, "secretRepository"); + this.envelopeCipher = Objects.requireNonNull(envelopeCipher, "envelopeCipher"); + } + + @Transactional + public SecretReference rotate( + String organizationId, + String connectionId, + SecretPurpose purpose, + SecretMaterial newMaterial, + Duration overlap, + String actorId, + Instant occurredAt + ) { + String tenant = requireText(organizationId, "organizationId"); + String connection = requireText(connectionId, "connectionId"); + SecretPurpose requiredPurpose = Objects.requireNonNull(purpose, "purpose"); + SecretMaterial material = Objects.requireNonNull(newMaterial, "newMaterial"); + Duration rotationOverlap = requireOverlap(overlap); + String actor = requireText(actorId, "actorId"); + Instant now = Objects.requireNonNull(occurredAt, "occurredAt"); + + connectionRepository.lockByOrganizationIdAndId(tenant, connection) + .orElseThrow(() -> new DomainBadRequestException( + "error.loginConnection.notFound" + )); + List existing = + secretRepository.findAllForUpdate(tenant, connection); + long nextVersion = existing.stream() + .map(LoginConnectionSecretVersion::getBindingVersion) + .max(Comparator.naturalOrder()) + .orElse(0L) + 1; + + existing.stream() + .filter(secret -> secret.getPurpose().equals(requiredPurpose)) + .filter(secret -> secret.getStatus() == LoginConnectionSecretStatus.RETIRING) + .forEach(secret -> { + secret.revoke(actor, now); + secretRepository.saveAndFlush(secret); + }); + existing.stream() + .filter(secret -> secret.getPurpose().equals(requiredPurpose)) + .filter(secret -> secret.getStatus() == LoginConnectionSecretStatus.CURRENT) + .findFirst() + .ifPresent(secret -> { + if (rotationOverlap.isZero()) { + secret.revoke(actor, now); + } else { + secret.retireUntil(now.plus(rotationOverlap), now); + } + secretRepository.saveAndFlush(secret); + }); + + SecretBindingContext context = new SecretBindingContext( + tenant, + connection, + requiredPurpose, + nextVersion + ); + EncryptedSecretEnvelope envelope = envelopeCipher.encrypt(context, material); + LoginConnectionSecretVersion stored = secretRepository.saveAndFlush( + LoginConnectionSecretVersion.current( + tenant, + connection, + requiredPurpose, + nextVersion, + envelope, + actor, + now + ) + ); + return stored.reference(); + } + + @Transactional(readOnly = true) + public SecretConfigurationSummary describe( + String organizationId, + String connectionId, + SecretPurpose purpose + ) { + List versions = secretRepository.findAll( + requireText(organizationId, "organizationId"), + requireText(connectionId, "connectionId"), + Objects.requireNonNull(purpose, "purpose") + ); + LoginConnectionSecretVersion current = versions.stream() + .filter(secret -> secret.getStatus() == LoginConnectionSecretStatus.CURRENT) + .findFirst() + .orElse(null); + if (current == null) { + return SecretConfigurationSummary.notConfigured(); + } + Instant previousValidUntil = versions.stream() + .filter(secret -> secret.getStatus() == LoginConnectionSecretStatus.RETIRING) + .map(LoginConnectionSecretVersion::getValidUntil) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + return SecretConfigurationSummary.configured(current.getUpdatedAt(), previousValidUntil); + } + + @Transactional + public void revoke( + String organizationId, + String connectionId, + SecretPurpose purpose, + long bindingVersion, + String actorId, + Instant occurredAt + ) { + String tenant = requireText(organizationId, "organizationId"); + String connection = requireText(connectionId, "connectionId"); + SecretPurpose requiredPurpose = Objects.requireNonNull(purpose, "purpose"); + connectionRepository.lockByOrganizationIdAndId(tenant, connection) + .orElseThrow(() -> new DomainBadRequestException( + "error.loginConnection.notFound" + )); + LoginConnectionSecretVersion secret = secretRepository.findAllForUpdate(tenant, connection) + .stream() + .filter(candidate -> candidate.getPurpose().equals(requiredPurpose)) + .filter(candidate -> candidate.getBindingVersion() == bindingVersion) + .findFirst() + .orElseThrow(() -> new DomainBadRequestException( + "error.loginConnection.secret.notFound" + )); + secret.revoke( + requireText(actorId, "actorId"), + Objects.requireNonNull(occurredAt, "occurredAt") + ); + secretRepository.saveAndFlush(secret); + } + + @Override + @Transactional(readOnly = true) + public SecretMaterial resolve( + SecretReference reference, + SecretPurpose expectedPurpose, + Instant now + ) { + SecretReference requiredReference = Objects.requireNonNull(reference, "reference"); + if (!requiredReference.purpose().equals( + Objects.requireNonNull(expectedPurpose, "expectedPurpose") + )) { + throw new SecretMaterialUnavailableException(); + } + LoginConnectionSecretVersion secret = secretRepository.findByReference(requiredReference) + .filter(candidate -> candidate.isResolvableAt( + Objects.requireNonNull(now, "now") + )) + .orElseThrow(SecretMaterialUnavailableException::new); + try { + return envelopeCipher.decrypt(requiredReference.context(), secret.envelope()); + } catch (SecretMaterialUnavailableException exception) { + throw exception; + } catch (RuntimeException exception) { + throw new SecretMaterialUnavailableException(); + } + } + + private static Duration requireOverlap(Duration overlap) { + Duration required = Objects.requireNonNull(overlap, "overlap"); + if (required.isNegative() || required.compareTo(MAX_ROTATION_OVERLAP) > 0) { + throw new DomainBadRequestException( + "error.loginConnection.secret.rotation.overlap.invalid" + ); + } + return required; + } + + private static String requireText(String value, String field) { + Objects.requireNonNull(value, field); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return normalized; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretSpringDataRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretSpringDataRepository.java new file mode 100644 index 00000000..c972d3a1 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretSpringDataRepository.java @@ -0,0 +1,42 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import jakarta.persistence.LockModeType; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +/** Internal Spring Data delegate for encrypted Login Connection Secret rows. */ +interface LoginConnectionSecretSpringDataRepository + extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select secretVersion + from LoginConnectionSecretVersion secretVersion + where secretVersion.scopeKey = :scopeKey + and secretVersion.connectionId = :connectionId + order by secretVersion.bindingVersion desc + """) + List findAllForUpdate( + @Param("scopeKey") String scopeKey, + @Param("connectionId") String connectionId + ); + + List + findAllByScopeKeyAndConnectionIdAndPurposeOrderByBindingVersionDesc( + String scopeKey, + String connectionId, + String purpose + ); + + Optional + findByScopeKeyAndConnectionIdAndPurposeAndBindingVersion( + String scopeKey, + String connectionId, + String purpose, + long bindingVersion + ); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretStatus.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretStatus.java new file mode 100644 index 00000000..2af8e57f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretStatus.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.auth.connection.secret; + +/** Runtime eligibility of one immutable encrypted Secret version. */ +public enum LoginConnectionSecretStatus { + CURRENT, + RETIRING, + REVOKED +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretVersion.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretVersion.java new file mode 100644 index 00000000..25ec58ad --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/LoginConnectionSecretVersion.java @@ -0,0 +1,246 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** Encrypted-at-rest Secret version whose raw envelope is restricted to this package. */ +@Entity +@Table(name = "login_connection_secret_version") +public class LoginConnectionSecretVersion { + + @Id + @Column(length = 64) + private String id; + + @Column(name = "organization_id", length = 64, updatable = false) + private String organizationId; + + @Column(name = "scope_key", length = 64, insertable = false, updatable = false) + private String scopeKey; + + @Column(name = "connection_id", nullable = false, length = 64, updatable = false) + private String connectionId; + + @Column(nullable = false, length = 128, updatable = false) + private String purpose; + + @Column(name = "binding_version", nullable = false, updatable = false) + private long bindingVersion; + + @Column(nullable = false, length = 32, updatable = false) + private String algorithm; + + @Column(name = "key_id", nullable = false, length = 128, updatable = false) + private String keyId; + + @Column(nullable = false, updatable = false) + private byte[] nonce; + + @Column(nullable = false, updatable = false) + private byte[] ciphertext; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private LoginConnectionSecretStatus status; + + @Column(name = "valid_until") + private Instant validUntil; + + @Column(name = "created_by", nullable = false, length = 128, updatable = false) + private String createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "revoked_by", length = 128) + private String revokedBy; + + @Column(name = "revoked_at") + private Instant revokedAt; + + @Version + @Column(nullable = false) + private long version; + + protected LoginConnectionSecretVersion() { + } + + private LoginConnectionSecretVersion( + String organizationId, + String connectionId, + SecretPurpose purpose, + long bindingVersion, + EncryptedSecretEnvelope envelope, + String createdBy, + Instant createdAt + ) { + this.id = UUID.randomUUID().toString(); + this.organizationId = organizationId == null + ? null + : requireText(organizationId, "organizationId"); + this.connectionId = requireText(connectionId, "connectionId"); + this.purpose = Objects.requireNonNull(purpose, "purpose").value(); + if (bindingVersion < 1) { + throw new IllegalArgumentException("bindingVersion must be positive"); + } + this.bindingVersion = bindingVersion; + EncryptedSecretEnvelope requiredEnvelope = Objects.requireNonNull(envelope, "envelope"); + this.algorithm = requiredEnvelope.algorithm(); + this.keyId = requiredEnvelope.keyId(); + this.nonce = requiredEnvelope.nonce(); + this.ciphertext = requiredEnvelope.ciphertext(); + this.status = LoginConnectionSecretStatus.CURRENT; + this.createdBy = requireText(createdBy, "createdBy"); + this.createdAt = Objects.requireNonNull(createdAt, "createdAt"); + this.updatedAt = createdAt; + } + + static LoginConnectionSecretVersion current( + String organizationId, + String connectionId, + SecretPurpose purpose, + long bindingVersion, + EncryptedSecretEnvelope envelope, + String createdBy, + Instant createdAt + ) { + return new LoginConnectionSecretVersion( + organizationId, + connectionId, + purpose, + bindingVersion, + envelope, + createdBy, + createdAt + ); + } + + void retireUntil(Instant retirementDeadline, Instant occurredAt) { + if (status != LoginConnectionSecretStatus.CURRENT) { + throw new IllegalStateException("Only the current Secret can enter retirement"); + } + Instant transitionTime = requireCurrentOrLater(occurredAt); + Instant deadline = Objects.requireNonNull(retirementDeadline, "retirementDeadline"); + if (!deadline.isAfter(transitionTime)) { + throw new IllegalArgumentException("Retirement deadline must be in the future"); + } + status = LoginConnectionSecretStatus.RETIRING; + validUntil = deadline; + updatedAt = transitionTime; + } + + void revoke(String actorId, Instant occurredAt) { + if (status == LoginConnectionSecretStatus.REVOKED) { + return; + } + Instant transitionTime = requireCurrentOrLater(occurredAt); + status = LoginConnectionSecretStatus.REVOKED; + revokedBy = requireText(actorId, "actorId"); + revokedAt = transitionTime; + updatedAt = transitionTime; + } + + boolean isResolvableAt(Instant instant) { + Instant now = Objects.requireNonNull(instant, "instant"); + return status == LoginConnectionSecretStatus.CURRENT + || (status == LoginConnectionSecretStatus.RETIRING + && validUntil != null + && now.isBefore(validUntil)); + } + + EncryptedSecretEnvelope envelope() { + return new EncryptedSecretEnvelope(algorithm, keyId, nonce, ciphertext); + } + + SecretReference reference() { + return new SecretReference( + organizationId == null ? SecretReference.PLATFORM_SCOPE_KEY : organizationId, + connectionId, + new SecretPurpose(purpose), + bindingVersion + ); + } + + private Instant requireCurrentOrLater(Instant occurredAt) { + Instant transitionTime = Objects.requireNonNull(occurredAt, "occurredAt"); + if (transitionTime.isBefore(updatedAt)) { + throw new IllegalArgumentException("Secret transition timestamp is stale"); + } + return transitionTime; + } + + private static String requireText(String value, String field) { + Objects.requireNonNull(value, field); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return normalized; + } + + public String getId() { + return id; + } + + public String getOrganizationId() { + return organizationId; + } + + public String getConnectionId() { + return connectionId; + } + + public SecretPurpose getPurpose() { + return new SecretPurpose(purpose); + } + + public long getBindingVersion() { + return bindingVersion; + } + + public LoginConnectionSecretStatus getStatus() { + return status; + } + + public Instant getValidUntil() { + return validUntil; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public Instant getRevokedAt() { + return revokedAt; + } + + public long getVersion() { + return version; + } + + @Override + public String toString() { + return "LoginConnectionSecretVersion[id=" + id + + ", organizationId=" + organizationId + + ", connectionId=" + connectionId + + ", purpose=" + purpose + + ", bindingVersion=" + bindingVersion + + ", status=" + status + + ", envelope=]"; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretBindingContext.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretBindingContext.java new file mode 100644 index 00000000..317e7575 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretBindingContext.java @@ -0,0 +1,44 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** Coordinates cryptographically bound into a Secret envelope as authenticated data. */ +public record SecretBindingContext( + String scopeKey, + String connectionId, + SecretPurpose purpose, + long bindingVersion +) { + + public SecretBindingContext { + scopeKey = requireText(scopeKey, "scopeKey"); + connectionId = requireText(connectionId, "connectionId"); + purpose = Objects.requireNonNull(purpose, "purpose"); + if (bindingVersion < 1) { + throw new IllegalArgumentException("bindingVersion must be positive"); + } + } + + byte[] authenticatedData(String algorithm, String keyId) { + return String.join( + "\n", + "skillhub-login-secret-v1", + scopeKey, + connectionId, + purpose.value(), + Long.toString(bindingVersion), + requireText(algorithm, "algorithm"), + requireText(keyId, "keyId") + ).getBytes(StandardCharsets.UTF_8); + } + + private static String requireText(String value, String field) { + Objects.requireNonNull(value, field); + String normalized = value.trim(); + if (normalized.isEmpty() || normalized.indexOf('\n') >= 0 || normalized.indexOf('\r') >= 0) { + throw new IllegalArgumentException(field + " is invalid"); + } + return normalized; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretConfigurationSummary.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretConfigurationSummary.java new file mode 100644 index 00000000..650c7786 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretConfigurationSummary.java @@ -0,0 +1,42 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import java.time.Instant; +import java.util.Objects; + +/** Redacted management projection safe for repository and future API boundaries. */ +public record SecretConfigurationSummary( + boolean configured, + Instant updatedAt, + Instant previousValidUntil +) { + + public SecretConfigurationSummary { + if (configured) { + Objects.requireNonNull(updatedAt, "updatedAt"); + } else if (updatedAt != null || previousValidUntil != null) { + throw new IllegalArgumentException("Unconfigured Secret cannot expose timestamps"); + } + } + + public static SecretConfigurationSummary configured( + Instant updatedAt, + Instant previousValidUntil + ) { + return new SecretConfigurationSummary( + true, + Objects.requireNonNull(updatedAt, "updatedAt"), + previousValidUntil + ); + } + + public static SecretConfigurationSummary notConfigured() { + return new SecretConfigurationSummary(false, null, null); + } + + @Override + public String toString() { + return "SecretConfigurationSummary[configured=" + configured + + ", updatedAt=" + updatedAt + + ", previousValidUntil=" + previousValidUntil + "]"; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretEnvelopeCipher.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretEnvelopeCipher.java new file mode 100644 index 00000000..628baf44 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretEnvelopeCipher.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.auth.connection.secret; + +/** Authenticated encryption seam used by Secret persistence. */ +public interface SecretEnvelopeCipher { + + EncryptedSecretEnvelope encrypt(SecretBindingContext context, SecretMaterial plaintext); + + SecretMaterial decrypt(SecretBindingContext context, EncryptedSecretEnvelope envelope); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretEnvelopeKey.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretEnvelopeKey.java new file mode 100644 index 00000000..f19d6408 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretEnvelopeKey.java @@ -0,0 +1,53 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import java.util.Arrays; +import java.util.Objects; +import java.util.regex.Pattern; + +/** Erasable AES-256 envelope key identified by a non-secret rotation key id. */ +public final class SecretEnvelopeKey implements AutoCloseable { + + private static final Pattern KEY_ID_FORMAT = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{2,127}"); + private static final int AES_256_KEY_BYTES = 32; + + private final String keyId; + private byte[] keyBytes; + + public SecretEnvelopeKey(String keyId, byte[] keyBytes) { + Objects.requireNonNull(keyId, "keyId"); + String normalizedKeyId = keyId.trim(); + if (!KEY_ID_FORMAT.matcher(normalizedKeyId).matches()) { + throw new IllegalArgumentException("Secret envelope key id format is invalid"); + } + Objects.requireNonNull(keyBytes, "keyBytes"); + if (keyBytes.length != AES_256_KEY_BYTES) { + throw new IllegalArgumentException("Secret envelope key must contain 32 bytes"); + } + this.keyId = normalizedKeyId; + this.keyBytes = keyBytes.clone(); + } + + public String keyId() { + return keyId; + } + + public synchronized byte[] copyKeyBytes() { + if (keyBytes == null) { + throw new IllegalStateException("Secret envelope key is closed"); + } + return keyBytes.clone(); + } + + @Override + public synchronized void close() { + if (keyBytes != null) { + Arrays.fill(keyBytes, (byte) 0); + keyBytes = null; + } + } + + @Override + public String toString() { + return "SecretEnvelopeKey[]"; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretEnvelopeKeyring.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretEnvelopeKeyring.java new file mode 100644 index 00000000..f65342e1 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretEnvelopeKeyring.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import java.util.Optional; + +/** Key-provider seam for local encrypted storage or an externally managed KMS/Vault. */ +public interface SecretEnvelopeKeyring { + + SecretEnvelopeKey activeKey(); + + Optional findById(String keyId); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretMaterial.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretMaterial.java new file mode 100644 index 00000000..b133c794 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretMaterial.java @@ -0,0 +1,49 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import java.util.Arrays; +import java.util.Objects; +import java.util.function.Function; + +/** Short-lived, explicitly erasable Secret bytes with no raw accessor. */ +public final class SecretMaterial implements AutoCloseable { + + private byte[] bytes; + + private SecretMaterial(byte[] bytes) { + if (bytes.length == 0) { + throw new IllegalArgumentException("Secret material must not be empty"); + } + this.bytes = bytes; + } + + public static SecretMaterial copyOf(byte[] source) { + Objects.requireNonNull(source, "source"); + return new SecretMaterial(source.clone()); + } + + public synchronized T use(Function operation) { + Objects.requireNonNull(operation, "operation"); + if (bytes == null) { + throw new IllegalStateException("Secret material is closed"); + } + byte[] workingCopy = bytes.clone(); + try { + return operation.apply(workingCopy); + } finally { + Arrays.fill(workingCopy, (byte) 0); + } + } + + @Override + public synchronized void close() { + if (bytes != null) { + Arrays.fill(bytes, (byte) 0); + bytes = null; + } + } + + @Override + public String toString() { + return "SecretMaterial[]"; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretMaterialResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretMaterialResolver.java new file mode 100644 index 00000000..1275d909 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretMaterialResolver.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import java.time.Instant; + +/** Runtime-only Secret material boundary used by protocol Adapters. */ +public interface SecretMaterialResolver { + + SecretMaterial resolve( + SecretReference reference, + SecretPurpose expectedPurpose, + Instant now + ); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretMaterialUnavailableException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretMaterialUnavailableException.java new file mode 100644 index 00000000..0dec5326 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretMaterialUnavailableException.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.auth.connection.secret; + +/** Privacy-preserving data-plane failure shared by all unavailable Secret causes. */ +public final class SecretMaterialUnavailableException extends RuntimeException { + + public SecretMaterialUnavailableException() { + super("Secret material is unavailable"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretPurpose.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretPurpose.java new file mode 100644 index 00000000..d9195659 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretPurpose.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** Stable, extensible purpose key used to prevent Secret reuse across protocols. */ +public record SecretPurpose(String value) { + + private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9._-]{2,127}"); + + public static final SecretPurpose LOGIN_CLIENT_SECRET = + new SecretPurpose("login.client-secret"); + + public SecretPurpose { + Objects.requireNonNull(value, "value"); + value = value.trim(); + if (!FORMAT.matcher(value).matches()) { + throw new IllegalArgumentException("Secret purpose format is invalid"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretReference.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretReference.java new file mode 100644 index 00000000..04257058 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/SecretReference.java @@ -0,0 +1,36 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import java.util.Objects; + +/** Non-secret, version-pinned reference stored by an immutable connection revision. */ +public record SecretReference( + String scopeKey, + String connectionId, + SecretPurpose purpose, + long bindingVersion +) { + + public static final String PLATFORM_SCOPE_KEY = "@platform"; + + public SecretReference { + scopeKey = requireText(scopeKey, "scopeKey"); + connectionId = requireText(connectionId, "connectionId"); + purpose = Objects.requireNonNull(purpose, "purpose"); + if (bindingVersion < 1) { + throw new IllegalArgumentException("bindingVersion must be positive"); + } + } + + SecretBindingContext context() { + return new SecretBindingContext(scopeKey, connectionId, purpose, bindingVersion); + } + + private static String requireText(String value, String field) { + Objects.requireNonNull(value, field); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return normalized; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/package-info.java new file mode 100644 index 00000000..df88f2ac --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/secret/package-info.java @@ -0,0 +1,2 @@ +/** Encrypted, version-pinned and redacted Secret storage for enterprise connections. */ +package com.iflytek.skillhub.auth.connection.secret; diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/control/StoredLoginConnectionRevisionSecretReferenceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/control/StoredLoginConnectionRevisionSecretReferenceTest.java new file mode 100644 index 00000000..215d7e22 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/control/StoredLoginConnectionRevisionSecretReferenceTest.java @@ -0,0 +1,69 @@ +package com.iflytek.skillhub.auth.connection.control; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.auth.connection.core.AdapterKey; +import com.iflytek.skillhub.auth.connection.secret.SecretPurpose; +import com.iflytek.skillhub.auth.connection.secret.SecretReference; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class StoredLoginConnectionRevisionSecretReferenceTest { + + private static final Instant NOW = Instant.parse("2026-09-08T06:00:00Z"); + + @Test + void materializationExpandsBindingVersionIntoTenantBoundClientSecretReference() { + LoginConnection connection = LoginConnection.createOrganization( + "organization-1", + "Corporate OIDC", + new AdapterKey("oidc"), + "identity-admin", + NOW + ); + StoredLoginConnectionRevision revision = revision(connection, 7L); + + assertThat(revision.snapshot(connection).secretReference()).get().satisfies(reference -> { + assertThat(reference.scopeKey()).isEqualTo("organization-1"); + assertThat(reference.connectionId()).isEqualTo(connection.getId()); + assertThat(reference.purpose()).isEqualTo(SecretPurpose.LOGIN_CLIENT_SECRET); + assertThat(reference.bindingVersion()).isEqualTo(7); + }); + assertThat(revision.snapshot(connection).toString()) + .contains("secretReference=present") + .doesNotContain("bindingVersion=7"); + } + + @Test + void platformRevisionMaterializesTheReservedPlatformSecretScope() { + LoginConnection platform = LoginConnection.createPlatform( + "github", + "GitHub", + new AdapterKey("oauth"), + "platform-admin", + NOW + ); + + assertThat(revision(platform, 1L).snapshot(platform).secretReference()) + .get() + .extracting(reference -> reference.scopeKey()) + .isEqualTo(SecretReference.PLATFORM_SCOPE_KEY); + } + + private static StoredLoginConnectionRevision revision( + LoginConnection connection, + Long secretBindingVersion + ) { + return StoredLoginConnectionRevision.create( + connection.getId(), + 1, + "1.0", + 1, + "[\"IDENTITY_ASSERTION\"]", + "{\"issuer\":\"https://id.example.com\"}", + secretBindingVersion, + "identity-admin", + NOW + ); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/secret/AesGcmSecretEnvelopeCipherTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/secret/AesGcmSecretEnvelopeCipherTest.java new file mode 100644 index 00000000..a65e07f2 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/secret/AesGcmSecretEnvelopeCipherTest.java @@ -0,0 +1,160 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.security.SecureRandom; +import java.util.Arrays; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class AesGcmSecretEnvelopeCipherTest { + + private static final SecretBindingContext CONTEXT = new SecretBindingContext( + "organization-1", + "connection-1", + SecretPurpose.LOGIN_CLIENT_SECRET, + 1 + ); + + private final InMemorySecretEnvelopeKeyring keyring = keyring("key-2026-09", (byte) 7); + private final AesGcmSecretEnvelopeCipher cipher = + new AesGcmSecretEnvelopeCipher(keyring, new SecureRandom()); + + @AfterEach + void closeKeyring() { + keyring.close(); + } + + @Test + void encryptsWithRandomNonceAndDecryptsOnlyInTheBoundContext() { + EncryptedSecretEnvelope first; + EncryptedSecretEnvelope second; + try (SecretMaterial secret = SecretMaterial.copyOf("client-secret-value".getBytes(UTF_8))) { + first = cipher.encrypt(CONTEXT, secret); + second = cipher.encrypt(CONTEXT, secret); + } + + assertThat(first.nonce()).hasSize(12).isNotEqualTo(second.nonce()); + assertThat(first.ciphertext()).isNotEqualTo(second.ciphertext()); + try (SecretMaterial decrypted = cipher.decrypt(CONTEXT, first)) { + assertThat(read(decrypted)).isEqualTo("client-secret-value"); + } + + SecretBindingContext anotherConnection = new SecretBindingContext( + "organization-1", + "connection-2", + SecretPurpose.LOGIN_CLIENT_SECRET, + 1 + ); + assertUnavailable(() -> cipher.decrypt(anotherConnection, first)); + } + + @Test + void failsClosedForTamperingOrUnknownMasterKeyWithoutLeakingDetails() { + EncryptedSecretEnvelope encrypted; + try (SecretMaterial secret = SecretMaterial.copyOf("do-not-log-me".getBytes(UTF_8))) { + encrypted = cipher.encrypt(CONTEXT, secret); + } + byte[] tampered = encrypted.ciphertext(); + tampered[tampered.length - 1] ^= 1; + EncryptedSecretEnvelope changed = new EncryptedSecretEnvelope( + encrypted.algorithm(), + encrypted.keyId(), + encrypted.nonce(), + tampered + ); + + assertUnavailable(() -> cipher.decrypt(CONTEXT, changed)); + + try (InMemorySecretEnvelopeKeyring anotherKeyring = keyring("another-key", (byte) 9)) { + AesGcmSecretEnvelopeCipher anotherCipher = + new AesGcmSecretEnvelopeCipher(anotherKeyring, new SecureRandom()); + assertUnavailable(() -> anotherCipher.decrypt(CONTEXT, encrypted)); + } + + assertThat(encrypted.toString()) + .doesNotContain("do-not-log-me", encrypted.keyId()) + .contains(""); + } + + @Test + void secretAndKeyBuffersAreDefensivelyCopiedAndWipedOnClose() { + byte[] source = "short-lived-secret".getBytes(UTF_8); + SecretMaterial material = SecretMaterial.copyOf(source); + Arrays.fill(source, (byte) 0); + + assertThat(read(material)).isEqualTo("short-lived-secret"); + assertThat(material.toString()).isEqualTo("SecretMaterial[]"); + material.close(); + + assertThatThrownBy(() -> read(material)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Secret material is closed"); + + byte[] keyBytes = new byte[32]; + Arrays.fill(keyBytes, (byte) 3); + SecretEnvelopeKey key = new SecretEnvelopeKey("key-copy-test", keyBytes); + Arrays.fill(keyBytes, (byte) 0); + assertThat(key.copyKeyBytes()).containsOnly((byte) 3); + assertThat(key.toString()).isEqualTo("SecretEnvelopeKey[]"); + key.close(); + } + + @Test + void keyringRotationCanDecryptOldEnvelopeWithoutReusingTheRetiredKeyForWrites() { + EncryptedSecretEnvelope oldEnvelope; + try (SecretMaterial material = SecretMaterial.copyOf("old-envelope".getBytes(UTF_8))) { + oldEnvelope = cipher.encrypt(CONTEXT, material); + } + + SecretEnvelopeKey newKey = key("key-2026-10", (byte) 10); + SecretEnvelopeKey oldKey = key("key-2026-09", (byte) 7); + try (newKey; + oldKey; + InMemorySecretEnvelopeKeyring rotatedKeyring = + new InMemorySecretEnvelopeKeyring(newKey, oldKey)) { + AesGcmSecretEnvelopeCipher rotatedCipher = + new AesGcmSecretEnvelopeCipher(rotatedKeyring, new SecureRandom()); + try (SecretMaterial decrypted = rotatedCipher.decrypt(CONTEXT, oldEnvelope)) { + assertThat(read(decrypted)).isEqualTo("old-envelope"); + } + try (SecretMaterial next = SecretMaterial.copyOf("new-envelope".getBytes(UTF_8))) { + assertThat(rotatedCipher.encrypt(CONTEXT, next).keyId()) + .isEqualTo("key-2026-10"); + } + } + } + + private static String read(SecretMaterial material) { + return material.use(bytes -> new String(bytes, UTF_8)); + } + + private static InMemorySecretEnvelopeKeyring keyring(String keyId, byte value) { + SecretEnvelopeKey key = key(keyId, value); + try (key) { + return new InMemorySecretEnvelopeKeyring(key); + } + } + + private static SecretEnvelopeKey key(String keyId, byte value) { + byte[] bytes = new byte[32]; + Arrays.fill(bytes, value); + SecretEnvelopeKey key = new SecretEnvelopeKey(keyId, bytes); + Arrays.fill(bytes, (byte) 0); + return key; + } + + private static void assertUnavailable(ThrowingOperation operation) { + assertThatThrownBy(operation::run) + .isInstanceOf(SecretMaterialUnavailableException.class) + .hasMessage("Secret material is unavailable") + .hasNoCause(); + } + + @FunctionalInterface + private interface ThrowingOperation { + void run(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/secret/SecretConfigurationSummaryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/secret/SecretConfigurationSummaryTest.java new file mode 100644 index 00000000..e2818130 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/secret/SecretConfigurationSummaryTest.java @@ -0,0 +1,26 @@ +package com.iflytek.skillhub.auth.connection.secret; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class SecretConfigurationSummaryTest { + + @Test + void managementDtoAndToStringExposeOnlyNonSecretMetadata() throws Exception { + SecretConfigurationSummary summary = SecretConfigurationSummary.configured( + Instant.parse("2026-09-08T06:00:00Z"), + Instant.parse("2026-09-08T07:00:00Z") + ); + + String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(summary); + + assertThat(json) + .contains("\"configured\":true", "\"updatedAt\"", "\"previousValidUntil\"") + .doesNotContain("value", "ciphertext", "nonce", "keyId", "clientSecret"); + assertThat(summary.toString()) + .doesNotContain("ciphertext", "nonce", "keyId", "clientSecret"); + } +}