Skip to content

Commit

Permalink
feature: Add DDC Access token validation method
Browse files Browse the repository at this point in the history
  • Loading branch information
skambalin committed Sep 18, 2024
1 parent 4852335 commit e08e8fb
Show file tree
Hide file tree
Showing 7 changed files with 160 additions and 16 deletions.
2 changes: 1 addition & 1 deletion packages/blockchain/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions packages/blockchain/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
4 changes: 3 additions & 1 deletion packages/ddc-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export {
MAINNET,
AuthToken,
AuthTokenOperation,
StorageNodeMode,
Cid,
type DagNodeStoreOptions,
type Signer,
} from '@cere-ddc-sdk/ddc';
Expand All @@ -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';
89 changes: 79 additions & 10 deletions packages/ddc/src/auth/AuthToken.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -18,6 +25,11 @@ export type AuthTokenParams = Omit<Payload, 'subject' | 'prev' | 'pieceCid'> & {
expiresIn?: number;
subject?: AccountId;
prev?: AuthToken | string;

/**
* Alias for `prev`.
*/
parent?: AuthToken | string;
};

/**
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
*
Expand Down Expand Up @@ -157,10 +181,33 @@ export class AuthToken {
return this;
}

async validate(): Promise<AuthToken> {
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.
*
Expand All @@ -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');
Expand All @@ -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.
*
Expand All @@ -211,7 +280,7 @@ export class AuthToken {
return token;
}

return this.fromProto(Token.fromBinary(base58.decode(token)));
return this.fromString(token);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions tests/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Logs
logs
59 changes: 59 additions & 0 deletions tests/specs/Auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 5 additions & 4 deletions tests/specs/DdcApis.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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}`;

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -333,6 +333,7 @@ describe.each(transportsVariants)('DDC APIs ($name)', ({ transport }) => {
signer: createRandomSigner(),
enableAcks: false,
logLevel,
logOptions,
});

const contentStream = await unfairFileApi.getFile({
Expand Down

0 comments on commit e08e8fb

Please sign in to comment.