Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: Add DDC Access token validation method #268

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading