feat(identity): secure login connection secrets

This commit is contained in:
XiaoSeS 2026-09-08 05:57:45 +08:00
parent 3c577eaea9
commit a2a86d3bb6
31 changed files with 2213 additions and 6 deletions

View file

@ -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);

View file

@ -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<String> queryStrings(String sql) throws SQLException {
try (Connection connection = connection();
Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery(sql)) {
var values = new ArrayList<String>();
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()
);
}
}

View file

@ -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=<redacted>")
.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();
}
}

View file

@ -25,6 +25,15 @@ public class JpaLoginConnectionRepositoryAdapter implements LoginConnectionRepos
);
}
@Override
@Transactional
public Optional<LoginConnection> lockByOrganizationIdAndId(String organizationId, String id) {
return delegate.lockByOrganizationIdAndId(
requireText(organizationId, "organizationId"),
requireText(id, "id")
);
}
@Override
@Transactional(readOnly = true)
public Optional<LoginConnection> findByPublicHandle(ConnectionHandle handle) {

View file

@ -8,6 +8,9 @@ public interface LoginConnectionRepository {
Optional<LoginConnection> findByOrganizationIdAndId(String organizationId, String id);
/** Serializes high-risk control-plane mutations such as Secret rotation. */
Optional<LoginConnection> lockByOrganizationIdAndId(String organizationId, String id);
/** Dedicated data-plane lookup; the random handle reveals no tenant management identifier. */
Optional<LoginConnection> findByPublicHandle(ConnectionHandle handle);

View file

@ -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<Long> secretBindingVersion
Optional<SecretReference> 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=<redacted>, typedConfigJson=<redacted>, secretBindingVersion="
+ (secretBindingVersion.isPresent() ? "present" : "none") + "]";
+ ", capabilitiesJson=<redacted>, typedConfigJson=<redacted>, secretReference="
+ (secretReference.isPresent() ? "present" : "none") + "]";
}
}

View file

@ -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<LoginConnection, String> {
Optional<LoginConnection> findByOrganizationIdAndId(String organizationId, String id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select connection
from LoginConnection connection
where connection.organizationId = :organizationId and connection.id = :id
""")
Optional<LoginConnection> lockByOrganizationIdAndId(
@Param("organizationId") String organizationId,
@Param("id") String id
);
Optional<LoginConnection> findByPublicHandle(String publicHandle);
}

View file

@ -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> 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()) {

View file

@ -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);
}
}
}

View file

@ -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[<redacted>]";
}
}

View file

@ -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<String, byte[]> 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<SecretEnvelopeKey> findById(String keyId) {
Objects.requireNonNull(keyId, "keyId");
return copy(keyId);
}
private Optional<SecretEnvelopeKey> 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[<redacted>]";
}
}

View file

@ -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<LoginConnectionSecretVersion> findAllForUpdate(
String scopeKey,
String connectionId
) {
return delegate.findAllForUpdate(
requireText(scopeKey, "scopeKey"),
requireText(connectionId, "connectionId")
);
}
@Override
@Transactional(readOnly = true)
public List<LoginConnectionSecretVersion> 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<LoginConnectionSecretVersion> 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;
}
}

View file

@ -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<LoginConnectionSecretVersion> findAllForUpdate(
String scopeKey,
String connectionId
);
List<LoginConnectionSecretVersion> findAll(
String scopeKey,
String connectionId,
SecretPurpose purpose
);
Optional<LoginConnectionSecretVersion> findByReference(SecretReference reference);
LoginConnectionSecretVersion saveAndFlush(LoginConnectionSecretVersion secretVersion);
}

View file

@ -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<LoginConnectionSecretVersion> 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<LoginConnectionSecretVersion> 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;
}
}

View file

@ -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<LoginConnectionSecretVersion, String> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select secretVersion
from LoginConnectionSecretVersion secretVersion
where secretVersion.scopeKey = :scopeKey
and secretVersion.connectionId = :connectionId
order by secretVersion.bindingVersion desc
""")
List<LoginConnectionSecretVersion> findAllForUpdate(
@Param("scopeKey") String scopeKey,
@Param("connectionId") String connectionId
);
List<LoginConnectionSecretVersion>
findAllByScopeKeyAndConnectionIdAndPurposeOrderByBindingVersionDesc(
String scopeKey,
String connectionId,
String purpose
);
Optional<LoginConnectionSecretVersion>
findByScopeKeyAndConnectionIdAndPurposeAndBindingVersion(
String scopeKey,
String connectionId,
String purpose,
long bindingVersion
);
}

View file

@ -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
}

View file

@ -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=<redacted>]";
}
}

View file

@ -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;
}
}

View file

@ -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 + "]";
}
}

View file

@ -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);
}

View file

@ -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[<redacted>]";
}
}

View file

@ -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<SecretEnvelopeKey> findById(String keyId);
}

View file

@ -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> T use(Function<byte[], T> 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[<redacted>]";
}
}

View file

@ -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
);
}

View file

@ -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");
}
}

View file

@ -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");
}
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,2 @@
/** Encrypted, version-pinned and redacted Secret storage for enterprise connections. */
package com.iflytek.skillhub.auth.connection.secret;

View file

@ -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
);
}
}

View file

@ -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("<redacted>");
}
@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[<redacted>]");
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[<redacted>]");
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();
}
}

View file

@ -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");
}
}