From e08e8fb72591a707cbdcf8dcfdd1ad67a6625c78 Mon Sep 17 00:00:00 2001 From: Sergey Kambalin Date: Wed, 18 Sep 2024 12:39:20 +0600 Subject: [PATCH] feature: Add DDC Access token validation method --- packages/blockchain/src/index.ts | 2 +- packages/blockchain/src/utils/index.ts | 11 ++++ packages/ddc-client/src/index.ts | 4 +- packages/ddc/src/auth/AuthToken.ts | 89 +++++++++++++++++++++++--- tests/.gitignore | 2 + tests/specs/Auth.spec.ts | 59 +++++++++++++++++ tests/specs/DdcApis.spec.ts | 9 +-- 7 files changed, 160 insertions(+), 16 deletions(-) create mode 100644 tests/.gitignore diff --git a/packages/blockchain/src/index.ts b/packages/blockchain/src/index.ts index 4ca74e88..d00359f4 100644 --- a/packages/blockchain/src/index.ts +++ b/packages/blockchain/src/index.ts @@ -5,7 +5,7 @@ export { Blockchain, type Sendable, type SendResult, type Event } from './Blockc /** * Utilities */ -export { decodeAddress, encodeAddress, cryptoWaitReady, createRandomSigner } from './utils'; +export { decodeAddress, encodeAddress, cryptoWaitReady, createRandomSigner, isValidSignature } from './utils'; /** * Constants diff --git a/packages/blockchain/src/utils/index.ts b/packages/blockchain/src/utils/index.ts index c124f618..07d96fc0 100644 --- a/packages/blockchain/src/utils/index.ts +++ b/packages/blockchain/src/utils/index.ts @@ -18,3 +18,14 @@ export const createRandomSigner = (options: UriSignerOptions = {}) => { return new UriSigner(uri, options); }; + +export const isValidSignature = ( + message: string | Uint8Array, + signature: string | Uint8Array, + signer: string | Uint8Array, +) => { + const publicKey = typeof signer === 'string' ? decodeAddress(signer) : signer; + const { isValid } = cryptoUtil.signatureVerify(message, signature, publicKey); + + return isValid; +}; diff --git a/packages/ddc-client/src/index.ts b/packages/ddc-client/src/index.ts index 3fba5209..21e35b04 100644 --- a/packages/ddc-client/src/index.ts +++ b/packages/ddc-client/src/index.ts @@ -18,6 +18,8 @@ export { MAINNET, AuthToken, AuthTokenOperation, + StorageNodeMode, + Cid, type DagNodeStoreOptions, type Signer, } from '@cere-ddc-sdk/ddc'; @@ -30,4 +32,4 @@ export { type FileReadOptions, } from '@cere-ddc-sdk/file-storage'; -export type { BucketId, ClusterId, Bucket, AccountId } from '@cere-ddc-sdk/blockchain'; +export type { BucketId, ClusterId, Bucket, AccountId, Blockchain } from '@cere-ddc-sdk/blockchain'; diff --git a/packages/ddc/src/auth/AuthToken.ts b/packages/ddc/src/auth/AuthToken.ts index a266bfc3..3a234f75 100644 --- a/packages/ddc/src/auth/AuthToken.ts +++ b/packages/ddc/src/auth/AuthToken.ts @@ -1,5 +1,12 @@ import base58 from 'bs58'; -import { AccountId, Signer, decodeAddress, encodeAddress } from '@cere-ddc-sdk/blockchain'; +import { + AccountId, + Signer, + cryptoWaitReady, + decodeAddress, + encodeAddress, + isValidSignature, +} from '@cere-ddc-sdk/blockchain'; import { AUTH_TOKEN_EXPIRATION_TIME } from '../constants'; import { createSignature, mapSignature, Signature } from '../signature'; @@ -18,6 +25,11 @@ export type AuthTokenParams = Omit & { expiresIn?: number; subject?: AccountId; prev?: AuthToken | string; + + /** + * Alias for `prev`. + */ + parent?: AuthToken | string; }; /** @@ -54,7 +66,7 @@ export class AuthToken { expiresAt: params.expiresAt ?? Date.now() + expiresIn, subject: params.subject ? decodeAddress(params.subject) : undefined, pieceCid: params.pieceCid ? new Cid(params.pieceCid).toBytes() : undefined, - prev: AuthToken.maybeToken(params.prev)?.token, + prev: AuthToken.maybeToken(params.prev || params.parent)?.token, }; this.token = Token.create({ payload }); @@ -113,11 +125,14 @@ export class AuthToken { * Whether the token is properly signed. */ get isSigned() { - return this.subject ? this.signature?.signer === this.subject : !!this.signature; + return !!this.signature; } - private toBinary() { - return Token.toBinary(this.token); + /** + * The previous token in the delegation chain. + */ + get parent() { + return this.token.payload?.prev && AuthToken.fromProto(this.token.payload.prev); } private static fromProto(protoToken: Token) { @@ -128,6 +143,15 @@ export class AuthToken { return newToken; } + /** + * Converts the authentication token to a binary representation. + * + * @returns The token as a binary representation. + */ + toBinary() { + return Token.toBinary(this.token); + } + /** * Converts the authentication token to a string. * @@ -157,10 +181,33 @@ export class AuthToken { return this; } + async validate(): Promise { + const { payload } = this.token; + + if (this.expiresAt < Date.now()) { + throw new Error('Token is expired'); + } + + if (!this.signature) { + throw new Error('Token is not signed'); + } + + await cryptoWaitReady(); + + const unsignedToken = Token.create({ payload }); + const isValid = isValidSignature(Token.toBinary(unsignedToken), this.signature.value, this.signature.signer); + + if (!isValid) { + throw new Error('Invalid token signature'); + } + + return this.parent ? this.parent.validate() : this; + } + /** - * Creates an `AuthToken` from a string or another `AuthToken`. + * Creates a new `AuthToken` from a delegated token. * - * @param token - The token as a string or an `AuthToken`. + * @param parentToken - Delegated parent token as a string or an `AuthToken`. * * @returns An instance of the `AuthToken` class. * @@ -175,8 +222,8 @@ export class AuthToken { * console.log(authToken); * ``` */ - static from(token: string | AuthToken) { - const parent = this.maybeToken(token); + static from(parentToken: string | AuthToken) { + const parent = this.maybeToken(parentToken); if (!parent?.token.payload) { throw new Error('Invalid token'); @@ -190,6 +237,28 @@ export class AuthToken { }); } + /** + * Creates an `AuthToken` from a base58-encoded string. + * + * @param token - The base58-encoded string. + * + * @returns An instance of the `AuthToken` class. + * + * @throws Will throw an error if the token is invalid. + * + * @example + * + * ```typescript + * const token: string = '...'; + * const authToken = AuthToken.fromString(token); + * + * console.log(authToken); + * ``` + */ + static fromString(token: string) { + return this.fromProto(Token.fromBinary(base58.decode(token))); + } + /** * This static method is used to convert a token into an AuthToken object. * @@ -211,7 +280,7 @@ export class AuthToken { return token; } - return this.fromProto(Token.fromBinary(base58.decode(token))); + return this.fromString(token); } /** diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 00000000..7f822aeb --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,2 @@ +# Logs +logs \ No newline at end of file diff --git a/tests/specs/Auth.spec.ts b/tests/specs/Auth.spec.ts index 392cd82d..f1fe1ac5 100644 --- a/tests/specs/Auth.spec.ts +++ b/tests/specs/Auth.spec.ts @@ -91,6 +91,65 @@ describe('Auth', () => { }); }); + describe('Token validation', () => { + let rootToken: AuthToken; + let delegatedToken: AuthToken; + + beforeEach(async () => { + rootToken = await AuthToken.fullAccess().sign(ownerSigner); + delegatedToken = new AuthToken({ + parent: rootToken, + subject: userSigner.address, + operations: [AuthTokenOperation.GET], + }); + }); + + test('Valid token chain', async () => { + await delegatedToken.sign(ownerSigner); + const finalToken = await AuthToken.from(delegatedToken).sign(userSigner); + + expect(finalToken.validate()).resolves.not.toThrow(); + }); + + test('Last token of the chain is unsigned', async () => { + await delegatedToken.sign(ownerSigner); + const finalToken = AuthToken.from(delegatedToken); + + expect(finalToken.validate()).rejects.toThrow('Token is not signed'); + }); + + test('One token in the middle of the chain is unsigned', async () => { + const finalToken = await AuthToken.from(delegatedToken).sign(userSigner); + + expect(finalToken.validate()).rejects.toThrow('Token is not signed'); + }); + + test('Expired token', async () => { + const finalToken = new AuthToken({ + operations: [AuthTokenOperation.GET], + expiresAt: Date.parse('2021-01-01'), + }); + + expect(finalToken.validate()).rejects.toThrow('Token is expired'); + }); + + test('Invalid signature', async () => { + const finalToken = new AuthToken({ + operations: [AuthTokenOperation.GET], + expiresAt: Date.parse('2021-01-01'), + }); + + await finalToken.sign(userSigner); + + /** + * Change the signature value to an invalid one + */ + finalToken.signature!.value = new Uint8Array([1, 2, 3]); + + expect(finalToken.validate()).rejects.toThrow('Token is expired'); + }); + }); + describe('Bucket access', () => { let publicFileUri: FileUri; let privateFileUri: FileUri; diff --git a/tests/specs/DdcApis.spec.ts b/tests/specs/DdcApis.spec.ts index ad3e90a0..a57f896f 100644 --- a/tests/specs/DdcApis.spec.ts +++ b/tests/specs/DdcApis.spec.ts @@ -43,7 +43,7 @@ const apiVariants = [ ]; describe.each(transportsVariants)('DDC APIs ($name)', ({ transport }) => { - const { logLevel } = getClientConfig(); + const { logLevel, logOptions } = getClientConfig(); const bucketId = 1n; const testRunRandom = Math.round(Math.random() * 10 ** 5); const signer = new UriSigner(ROOT_USER_SEED); @@ -55,7 +55,7 @@ describe.each(transportsVariants)('DDC APIs ($name)', ({ transport }) => { }); describe.each(apiVariants)('DAG Api ($name)', ({ authenticate }) => { - const dagApi = new DagApi(transport, { signer, authenticate, logLevel }); + const dagApi = new DagApi(transport, { signer, authenticate, logLevel, logOptions }); const nodeData = new Uint8Array(randomBytes(10)); let nodeCid: Uint8Array; @@ -133,7 +133,7 @@ describe.each(transportsVariants)('DDC APIs ($name)', ({ transport }) => { }); describe('Cns Api', () => { - const cnsApi = new CnsApi(transport, { signer, logLevel }); + const cnsApi = new CnsApi(transport, { signer, logLevel, logOptions }); const testCid = new Cid('baebb4ifbvlaklsqk4ex2n2xfaghhrkd3bbqg53d2du4sdgsz7uixt25ycu').toBytes(); const alias = `dir/file-name-${testRunRandom}`; @@ -165,7 +165,7 @@ describe.each(transportsVariants)('DDC APIs ($name)', ({ transport }) => { }); describe.each(apiVariants)('File Api ($name)', ({ authenticate }) => { - const fileApi = new FileApi(transport, { signer, authenticate, logLevel }); + const fileApi = new FileApi(transport, { signer, authenticate, logLevel, logOptions }); const storeRawPiece = async (content: Content, meta?: PieceMeta) => fileApi.putRawPiece( @@ -333,6 +333,7 @@ describe.each(transportsVariants)('DDC APIs ($name)', ({ transport }) => { signer: createRandomSigner(), enableAcks: false, logLevel, + logOptions, }); const contentStream = await unfairFileApi.getFile({