diff --git a/.changeset/tender-lies-wonder.md b/.changeset/tender-lies-wonder.md new file mode 100644 index 0000000000..43124bb41c --- /dev/null +++ b/.changeset/tender-lies-wonder.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': minor +--- + +Adds the StaticTokenIssuer and StaticKeyStore, an alternative token issuer that can be used to sign the Authorization header using a predefined public/private key pair. diff --git a/.github/vale/Vocab/Backstage/accept.txt b/.github/vale/Vocab/Backstage/accept.txt index e6c0aa9632..8e7cb7d124 100644 --- a/.github/vale/Vocab/Backstage/accept.txt +++ b/.github/vale/Vocab/Backstage/accept.txt @@ -258,6 +258,7 @@ onboarding Onboarding OpenSearch OpenShift +openssl orgs padding paddings diff --git a/docs/auth/index.md b/docs/auth/index.md index a670e69baa..74433d157f 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -406,3 +406,50 @@ providerFactories: { ghe: providers.github.create(), }, ``` + +## Configuring token issuers + +By default, the Backstage authentication backend generates and manages its own signing keys automatically for any issued +Backstage tokens. However, these keys have a short lifetime and do not persist after instance restarts. + +Alternatively, users can provide their own public and private key files to sign issued tokens. This is beneficial in +scenarios where the token verification implementation aggressively caches the list of keys, and doesn't attempt to fetch +new ones even if they encounter an unknown key id. To enable this feature add the following configuration to your config +file: + +```yaml +auth: + keyStore: + provider: 'static' + static: + keys: + # Must be declared at least once and the first one will be used for signing */ + - keyId: 'primary' + publicKeyFile: /path/to/public.key + privateKeyFile: /path/to/private.key + algorithm: # Optional, algorithm used to generate the keys, defaults to ES256 + # More keys can be added so with future key rotations caches already know about it + - keyId: ... +``` + +The private key should be stored in the PKCS#8 format. The public key should be stored in the SPKI format. +You can generate the public/private key pair, using openssl and the ES256 algorithm by performing the following +steps: + +Generate a private key using the ES256 algorithm + +```sh +openssl ecparam -name prime256v1 -genkey -out private.ec.key +``` + +Convert it to PKCS#8 format + +```sh +openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.ec.key -out private.key +``` + +Extract the public key + +```sh +openssl ec -inform PEM -outform PEM -pubout -in private.key -out public.key +``` diff --git a/plugins/auth-backend/README.md b/plugins/auth-backend/README.md index ba0042c6f9..ce7525fb6b 100644 --- a/plugins/auth-backend/README.md +++ b/plugins/auth-backend/README.md @@ -158,6 +158,10 @@ To try out SAML, you can use the mock identity provider: [How to add an auth provider](https://github.com/backstage/backstage/blob/master/docs/auth/add-auth-provider.md) +## Token issuers + +[Configuring token issuers](https://github.com/backstage/backstage/blob/master/docs/auth/index.md) + ## Links - [The Backstage homepage](https://backstage.io) diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index f8953e1588..77e2c7357e 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -43,7 +43,7 @@ export interface Config { /** To control how to store JWK data in auth-backend */ keyStore?: { - provider?: 'database' | 'memory' | 'firestore'; + provider?: 'database' | 'memory' | 'firestore' | 'static'; firestore?: { /** The host to connect to */ host?: string; @@ -65,6 +65,21 @@ export interface Config { /** Timeout used for database operations. Defaults to 10000ms */ timeout?: number; }; + static?: { + /** Must be declared at least once and the first one will be used for signing */ + keys: Array<{ + /** Path to the public key file in the SPKI format */ + publicKeyFile: string; + /** Path to the matching private key file in the PKCS#8 format */ + privateKeyFile: string; + /** id to uniquely identify this key within the JWK set */ + keyId: string; + /** JWS "alg" (Algorithm) Header Parameter value. Defaults to ES256. + * Must match the algorithm used to generate the keys in the provided files + */ + algorithm?: string; + }>; + }; }; /** diff --git a/plugins/auth-backend/src/identity/KeyStores.ts b/plugins/auth-backend/src/identity/KeyStores.ts index cc35c0d84e..a19606c9a3 100644 --- a/plugins/auth-backend/src/identity/KeyStores.ts +++ b/plugins/auth-backend/src/identity/KeyStores.ts @@ -18,12 +18,12 @@ import { pickBy } from 'lodash'; import { LoggerService } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; - import { AuthDatabase } from '../database/AuthDatabase'; import { DatabaseKeyStore } from './DatabaseKeyStore'; import { FirestoreKeyStore } from './FirestoreKeyStore'; import { MemoryKeyStore } from './MemoryKeyStore'; import { KeyStore } from './types'; +import { StaticKeyStore, StaticKeyStoreConfig } from './StaticKeyStore'; type Options = { logger: LoggerService; @@ -75,6 +75,15 @@ export class KeyStores { return keyStore; } + if (provider === 'static') { + const settings = ks?.getOptional('static'); + if (settings === undefined) { + throw new Error(`Missing configuration for static keyStore provider`); + } + + await StaticKeyStore.create(settings); + } + throw new Error(`Unknown KeyStore provider: ${provider}`); } } diff --git a/plugins/auth-backend/src/identity/StaticKeyStore.test.ts b/plugins/auth-backend/src/identity/StaticKeyStore.test.ts new file mode 100644 index 0000000000..e0882efa31 --- /dev/null +++ b/plugins/auth-backend/src/identity/StaticKeyStore.test.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { promises as fs } from 'fs'; +import { StaticKeyStore, StaticKeyStoreConfig } from './StaticKeyStore'; +import { AnyJWK } from './types'; + +const publicKeyPath = '/mnt/public.pem'; +const privateKeyPath = 'mnt/private.pem'; +const privateKey = ` +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgR8Ja2ppMEgOm1KeY +Kpje00U1luybndt6yC263vcgeKqhRANCAAS+slUrS9JXgtHB1RcDnmlveuu4H3Zm +hQRjvYdO+Mg/3FJss6FaExESTzhPSr3X+be/exarkTMchbDXNEdCKwpn +-----END PRIVATE KEY----- +`.trim(); + +const publicKey = ` +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvrJVK0vSV4LRwdUXA55pb3rruB92 +ZoUEY72HTvjIP9xSbLOhWhMREk84T0q91/m3v3sWq5EzHIWw1zRHQisKZw== +-----END PUBLIC KEY----- +`.trim(); + +jest.mock('fs/promises'); + +describe('StaticKeyStore', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should provide keys from disk', async () => { + fs.readFile = jest.fn().mockImplementation(async (path: string, _: any) => { + if (path === publicKeyPath) { + return Promise.resolve(publicKey); + } + if (path === privateKeyPath) { + return Promise.resolve(privateKey); + } + + throw new Error('Unexpected path'); + }); + + const staticKeyStoreConfig: StaticKeyStoreConfig = { + keys: [ + { + publicKeyFile: publicKeyPath, + privateKeyFile: privateKeyPath, + keyId: '1', + algorithm: 'ES256', + }, + { + publicKeyFile: publicKeyPath, + privateKeyFile: privateKeyPath, + keyId: '2', + algorithm: 'ES256', + }, + ], + }; + const staticKeyStore = await StaticKeyStore.create(staticKeyStoreConfig); + + const keys = await staticKeyStore.listKeys(); + expect(keys.items.length).toEqual(2); + expect(keys.items[0].key).toMatchObject({ + kid: '1', + alg: 'ES256', + }); + expect(keys.items[1].key).toMatchObject({ + kid: '2', + alg: 'ES256', + }); + + const pk = staticKeyStore.getPrivateKey('1'); + expect(pk).toMatchObject({ + kid: '1', + alg: 'ES256', + }); + expect(pk.d).toBeDefined(); + }); + + it('should not allow users to add keys', async () => { + const staticKeyStoreConfig: StaticKeyStoreConfig = { keys: [] }; + const staticKeyStore = await StaticKeyStore.create(staticKeyStoreConfig); + + const key: AnyJWK = { + use: 'sig', + alg: 'ES256', + kid: '1', + kty: '1', + }; + expect(() => staticKeyStore.addKey(key)).toThrow( + 'Cannot add keys to the static key store', + ); + }); + + it('should not allow users to remove keys', async () => { + const staticKeyStoreConfig: StaticKeyStoreConfig = { keys: [] }; + const staticKeyStore = await StaticKeyStore.create(staticKeyStoreConfig); + expect(() => staticKeyStore.removeKeys(['1'])).toThrow( + 'Cannot remove keys from the static key store', + ); + }); +}); diff --git a/plugins/auth-backend/src/identity/StaticKeyStore.ts b/plugins/auth-backend/src/identity/StaticKeyStore.ts new file mode 100644 index 0000000000..6859d1f445 --- /dev/null +++ b/plugins/auth-backend/src/identity/StaticKeyStore.ts @@ -0,0 +1,166 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { AnyJWK, KeyStore, StoredKey } from './types'; +import { exportJWK, importPKCS8, importSPKI, JWK } from 'jose'; +import { KeyLike } from 'jose/dist/types/types'; +import { promises as fs } from 'fs'; + +export type KeyPair = { + publicKey: JWK; + privateKey: JWK; +}; + +export type StaticKeyConfig = { + publicKeyFile: string; + privateKeyFile: string; + keyId: string; + algorithm?: string; +}; + +export type StaticKeyStoreConfig = { + keys: StaticKeyConfig[]; +}; + +const DEFAULT_ALGORITHM = 'ES256'; + +/** + * Key store that loads predefined public/private key pairs from disk + * + * The private key should be represented using the PKCS#8 format, + * while the public key should be in the SPKI format. + * + * @remarks + * + * You can generate a public and private key pair, using + * openssl: + * + * Generate a private key using the ES256 algorithm + * ```sh + * openssl ecparam -name prime256v1 -genkey -out private.ec.key + * ``` + * Convert it to PKCS#8 format + * ```sh + * openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.ec.key -out private.key + * ``` + * Extract the public key + * ```sh + * openssl ec -inform PEM -outform PEM -pubout -in private.key -out public.key + * ``` + * + * Provide the paths to private.key and public.key as the respective + * private and public key paths in the StaticKeyStore.create(...) method. + */ +export class StaticKeyStore implements KeyStore { + private readonly keyPairs: KeyPair[]; + private readonly createdAt: Date; + + private constructor(keyPairs: KeyPair[]) { + this.keyPairs = keyPairs; + this.createdAt = new Date(); + } + + public static async create( + config: StaticKeyStoreConfig, + ): Promise { + const keyPairs = await Promise.all( + config.keys.map(async k => await this.loadKeyPair(k)), + ); + + return new StaticKeyStore(keyPairs); + } + + addKey(_key: AnyJWK): Promise { + throw new Error('Cannot add keys to the static key store'); + } + + listKeys(): Promise<{ items: StoredKey[] }> { + const keys = this.keyPairs.map(k => this.keyPairToStoredKey(k)); + return Promise.resolve({ items: keys }); + } + + getPrivateKey(keyId: string): JWK { + const keyPair = this.keyPairs.find(k => k.publicKey.kid === keyId); + if (keyPair === undefined) { + throw new Error(`Could not find key with keyId: ${keyId}`); + } + + return keyPair.privateKey; + } + + removeKeys(_kids: string[]): Promise { + throw new Error('Cannot remove keys from the static key store'); + } + + private keyPairToStoredKey(keyPair: KeyPair): StoredKey { + const publicKey = { + ...keyPair.publicKey, + use: 'sig', + }; + + return { + key: publicKey as AnyJWK, + createdAt: this.createdAt, + }; + } + + private static async loadKeyPair(options: StaticKeyConfig): Promise { + const algorithm = options.algorithm ?? DEFAULT_ALGORITHM; + const keyId = options.keyId; + const publicKey = await this.loadPublicKeyFromFile( + options.publicKeyFile, + keyId, + algorithm, + ); + const privateKey = await this.loadPrivateKeyFromFile( + options.privateKeyFile, + keyId, + algorithm, + ); + + return { publicKey, privateKey }; + } + + private static async loadPublicKeyFromFile( + path: string, + keyId: string, + algorithm: string, + ): Promise { + return this.loadKeyFromFile(path, keyId, algorithm, importSPKI); + } + + private static async loadPrivateKeyFromFile( + path: string, + keyId: string, + algorithm: string, + ): Promise { + return this.loadKeyFromFile(path, keyId, algorithm, importPKCS8); + } + + private static async loadKeyFromFile( + path: string, + keyId: string, + algorithm: string, + importer: (content: string, algorithm: string) => Promise, + ): Promise { + const content = await fs.readFile(path, { encoding: 'utf8', flag: 'r' }); + const key = await importer(content, algorithm); + const jwk = await exportJWK(key); + jwk.kid = keyId; + jwk.alg = algorithm; + + return jwk; + } +} diff --git a/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts b/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts new file mode 100644 index 0000000000..df792f3e58 --- /dev/null +++ b/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts @@ -0,0 +1,116 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { getVoidLogger } from '@backstage/backend-common'; +import { StaticTokenIssuer } from './StaticTokenIssuer'; +import { createLocalJWKSet, jwtVerify } from 'jose'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { StaticKeyStore } from './StaticKeyStore'; + +const logger = getVoidLogger(); +const entityRef = stringifyEntityRef({ + kind: 'User', + namespace: 'default', + name: 'name', +}); + +jest.mock('fs/promises'); +describe('StaticTokenIssuer', () => { + it('should issue valid tokens signed by the first listed key', async () => { + const staticKeyStore = { + listKeys: () => { + return Promise.resolve({ + items: [ + { + createdAt: new Date(), + key: { + kty: 'EC', + x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w', + y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc', + crv: 'P-256', + kid: '1', + alg: 'ES256', + use: 'sig', + }, + }, + { + createdAt: new Date(), + key: { + kty: 'EC', + x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w', + y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc', + crv: 'P-256', + kid: '2', + alg: 'ES256', + use: 'sig', + }, + }, + ], + }); + }, + getPrivateKey: (kid: string) => { + expect(kid).toEqual('1'); + return { + kty: 'EC', + x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w', + y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc', + crv: 'P-256', + d: 'R8Ja2ppMEgOm1KeYKpje00U1luybndt6yC263vcgeKo', + kid: '1', + alg: 'ES256', + }; + }, + }; + + const keyDurationSeconds = 86400; + const options = { + logger, + issuer: 'my-issuer', + sessionExpirationSeconds: keyDurationSeconds, + }; + const issuer = new StaticTokenIssuer( + options, + staticKeyStore as unknown as StaticKeyStore, + ); + const token = await issuer.issueToken({ + claims: { + sub: entityRef, + ent: [entityRef], + 'x-fancy-claim': 'my special claim', + aud: 'this value will be overridden', + }, + }); + + const { keys } = await issuer.listPublicKeys(); + const keyStore = createLocalJWKSet({ keys: keys }); + const verifyResult = await jwtVerify(token, keyStore); + expect(verifyResult.protectedHeader).toEqual({ + kid: '1', + alg: 'ES256', + }); + expect(verifyResult.payload).toEqual({ + iss: 'my-issuer', + aud: 'backstage', + sub: entityRef, + ent: [entityRef], + 'x-fancy-claim': 'my special claim', + iat: expect.any(Number), + exp: expect.any(Number), + }); + expect(verifyResult.payload.exp).toBe( + verifyResult.payload.iat! + keyDurationSeconds, + ); + }); +}); diff --git a/plugins/auth-backend/src/identity/StaticTokenIssuer.ts b/plugins/auth-backend/src/identity/StaticTokenIssuer.ts new file mode 100644 index 0000000000..77c13c7c33 --- /dev/null +++ b/plugins/auth-backend/src/identity/StaticTokenIssuer.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { AnyJWK, TokenIssuer, TokenParams } from './types'; +import { SignJWT, importJWK, JWK } from 'jose'; +import { parseEntityRef } from '@backstage/catalog-model'; +import { AuthenticationError } from '@backstage/errors'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import { StaticKeyStore } from './StaticKeyStore'; + +const MS_IN_S = 1000; + +export type Config = { + publicKeyFile: string; + privateKeyFile: string; + keyId: string; + algorithm?: string; +}; + +export type Options = { + logger: LoggerService; + /** Value of the issuer claim in issued tokens */ + issuer: string; + /** Expiration time of the JWT in seconds */ + sessionExpirationSeconds: number; + /** id to uniquely identify this key within the JWK set, defaults to '1' */ +}; + +/** + * A token issuer that issues tokens from predefined + * public/private key pair stored in the static key store. + */ +export class StaticTokenIssuer implements TokenIssuer { + private readonly issuer: string; + private readonly logger: LoggerService; + private readonly keyStore: StaticKeyStore; + private readonly sessionExpirationSeconds: number; + + public constructor(options: Options, keyStore: StaticKeyStore) { + this.issuer = options.issuer; + this.logger = options.logger; + this.sessionExpirationSeconds = options.sessionExpirationSeconds; + this.keyStore = keyStore; + } + + public async issueToken(params: TokenParams): Promise { + const key = await this.getSigningKey(); + + // TODO: code shared with TokenFactory.ts + const iss = this.issuer; + const { sub, ent, ...additionalClaims } = params.claims; + const aud = 'backstage'; + const iat = Math.floor(Date.now() / MS_IN_S); + const exp = iat + this.sessionExpirationSeconds; + + // Validate that the subject claim is a valid EntityRef + try { + parseEntityRef(sub); + } catch (error) { + throw new Error( + '"sub" claim provided by the auth resolver is not a valid EntityRef.', + ); + } + + this.logger.info(`Issuing token for ${sub}, with entities ${ent ?? []}`); + + if (!key.alg) { + throw new AuthenticationError('No algorithm was provided in the key'); + } + + return new SignJWT({ ...additionalClaims, iss, sub, ent, aud, iat, exp }) + .setProtectedHeader({ alg: key.alg, kid: key.kid }) + .setIssuer(iss) + .setAudience(aud) + .setSubject(sub) + .setIssuedAt(iat) + .setExpirationTime(exp) + .sign(await importJWK(key)); + } + + private async getSigningKey(): Promise { + const { items: keys } = await this.keyStore.listKeys(); + if (keys.length >= 1) { + return this.keyStore.getPrivateKey(keys[0].key.kid); + } + throw new Error('Keystore should hold at least 1 key'); + } + + public async listPublicKeys(): Promise<{ keys: AnyJWK[] }> { + const { items: keys } = await this.keyStore.listKeys(); + return { keys: keys.map(({ key }) => key) }; + } +} diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index e3bfc1c3f2..320870b3ee 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -38,6 +38,9 @@ import { Minimatch } from 'minimatch'; import { CatalogAuthResolverContext } from '../lib/resolvers'; import { AuthDatabase } from '../database/AuthDatabase'; import { BACKSTAGE_SESSION_EXPIRATION } from '../lib/session'; +import { TokenIssuer } from '../identity/types'; +import { StaticTokenIssuer } from '../identity/StaticTokenIssuer'; +import { StaticKeyStore } from '../identity/StaticKeyStore'; /** @public */ export type ProviderFactories = { [s: string]: AuthProviderFactory }; @@ -75,22 +78,34 @@ export async function createRouter( const authUrl = await discovery.getExternalBaseUrl('auth'); const authDb = AuthDatabase.create(database); + const sessionExpirationSeconds = BACKSTAGE_SESSION_EXPIRATION; + const keyStore = await KeyStores.fromConfig(config, { logger, database: authDb, }); - const keyDurationSeconds = BACKSTAGE_SESSION_EXPIRATION; - - const tokenIssuer = new TokenFactory({ - issuer: authUrl, - keyStore, - keyDurationSeconds, - logger: logger.child({ component: 'token-factory' }), - algorithm: - tokenFactoryAlgorithm ?? - config.getOptionalString('auth.identityTokenAlgorithm'), - }); + let tokenIssuer: TokenIssuer; + if (keyStore instanceof StaticKeyStore) { + tokenIssuer = new StaticTokenIssuer( + { + logger: logger.child({ component: 'token-factory' }), + issuer: authUrl, + sessionExpirationSeconds: sessionExpirationSeconds, + }, + keyStore as StaticKeyStore, + ); + } else { + tokenIssuer = new TokenFactory({ + issuer: authUrl, + keyStore, + keyDurationSeconds: sessionExpirationSeconds, + logger: logger.child({ component: 'token-factory' }), + algorithm: + tokenFactoryAlgorithm ?? + config.getOptionalString('auth.identityTokenAlgorithm'), + }); + } const secret = config.getOptionalString('auth.session.secret'); if (secret) { router.use(cookieParser(secret));