Skip to content
Merged
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
3 changes: 1 addition & 2 deletions chain-api/src/types/RegisterUserDto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ import { instanceToPlain, plainToInstance } from "class-transformer";
import { validate } from "class-validator";

import { signatures } from "../utils";
import { UserAlias, asValidUserAlias } from "./UserAlias";
import { asValidUserRef } from "./UserRef";
import { UserAlias } from "./UserAlias";
import { RegisterUserDto } from "./dtos";

describe("RegisterUserDto", () => {
Expand Down
15 changes: 15 additions & 0 deletions chain-api/src/types/dtos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,13 @@ export class RegisterUserDto extends SubmitCallDTO {
@IsNotEmpty()
public publicKey?: string;

@JSONSchema({ description: "Signature from the public key." })
@IsOptional()
@IsNotEmpty()
@SerializeIf((o) => !!o.publicKey)
@ValidateIf((o) => !o.signers)
public publicKeySignature?: string;

@JSONSchema({ description: "Signer user refs." })
@ValidateIf((o) => !o.publicKey)
@SerializeIf((o) => !o.publicKey)
Expand All @@ -583,6 +590,14 @@ export class RegisterUserDto extends SubmitCallDTO {
@IsInt()
@Min(1)
signatureQuorum?: number;

public withPublicKeySignedBy(privateKey: string): this {
const copied = instanceToInstance(this);
delete copied.publicKeySignature;

copied.publicKeySignature = signatures.getSignature(copied, privateKey);
return copied;
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11258,6 +11258,11 @@ The key is generated by the caller and should be unique for each DTO. You can us
"minLength": 1,
"type": "string",
},
"publicKeySignature": {
"description": "Signature from the public key.",
"minLength": 1,
"type": "string",
},
"signature": {
"description": "Signature of the DTO signed with caller's private key to be verified with user's public key saved on chain. The 'signature' field is optional for DTO, but is required for a transaction to be executed on chain.
Please consult [GalaChain SDK documentation](https://github.com/GalaChain/sdk/blob/main/docs/authorization.md#signature-based-authorization) on how to create signatures.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,11 @@ The key is generated by the caller and should be unique for each DTO. You can us
"minLength": 1,
"type": "string",
},
"publicKeySignature": {
"description": "Signature from the public key.",
"minLength": 1,
"type": "string",
},
"signature": {
"description": "Signature of the DTO signed with caller's private key to be verified with user's public key saved on chain. The 'signature' field is optional for DTO, but is required for a transaction to be executed on chain.
Please consult [GalaChain SDK documentation](https://github.com/GalaChain/sdk/blob/main/docs/authorization.md#signature-based-authorization) on how to create signatures.",
Expand Down
4 changes: 3 additions & 1 deletion chaincode/src/contracts/PublicKeyContract.multisig.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,9 @@ describe("PublicKeyContract Multisignature", () => {
user: alias,
publicKey: newKey.publicKey
});
const signedDto = dto.signed(process.env.DEV_ADMIN_PRIVATE_KEY as string);
const signedDto = dto
.withPublicKeySignedBy(newKey.privateKey)
.signed(process.env.DEV_ADMIN_PRIVATE_KEY as string);

// When
const response = await chaincode.invoke("PublicKeyContract:RegisterUser", signedDto);
Expand Down
131 changes: 123 additions & 8 deletions chaincode/src/contracts/PublicKeyContract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,18 @@ it("should serve proper API", async () => {
});

describe("RegisterUser", () => {
const publicKey =
"04215291d9d04aad96832bffe808acdc1d985b4b547c8b16f841e14e8fbfb11284d5a5a5c71d95bd520b90403abff8fe7ccf793e755baf69672ab6cf25b60fc942";
const ethAddress = signatures.getEthAddress(publicKey);

it("should register user", async () => {
// Given
const chaincode = new TestChaincode([PublicKeyContract]);
const keyPair = signatures.genKeyPair();
const publicKey = keyPair.publicKey;
const privateKey = keyPair.privateKey;
const ethAddress = signatures.getEthAddress(publicKey);

const dto = await createValidSubmitDTO(RegisterUserDto, { user: "client|user1" as UserAlias, publicKey });
const signedDto = dto.signed(process.env.DEV_ADMIN_PRIVATE_KEY as string);
const signedDto = dto
.withPublicKeySignedBy(privateKey)
.signed(process.env.DEV_ADMIN_PRIVATE_KEY as string);

// When
const response = await chaincode.invoke("PublicKeyContract:RegisterUser", signedDto);
Expand All @@ -101,6 +104,35 @@ describe("RegisterUser", () => {
);
});

it("should fail when public key signature is missing or invalid", async () => {
// Given
const chaincode = new TestChaincode([PublicKeyContract]);
const adminPrivateKey = process.env.DEV_ADMIN_PRIVATE_KEY as string;
const keyPair = signatures.genKeyPair();

// no public key signature (but DTO is signed by admin)
const dto1 = await createValidSubmitDTO(RegisterUserDto, {
user: "client|user1" as UserAlias,
publicKey: keyPair.publicKey
});
const signedDto1 = dto1.signed(adminPrivateKey);

// invalid public key signature (signed by wrong key)
const dto2 = dto1.withPublicKeySignedBy(adminPrivateKey);
const signedDto2 = dto2.signed(adminPrivateKey);

// When
const response1 = await chaincode.invoke("PublicKeyContract:RegisterUser", signedDto1);
const response2 = await chaincode.invoke("PublicKeyContract:RegisterUser", signedDto2);

// Then
expect(response1).toEqual(transactionErrorKey("VALIDATION_FAILED"));
expect(response1).toEqual(transactionErrorMessageContains("Public key signature is missing"));

expect(response2).toEqual(transactionErrorKey("VALIDATION_FAILED"));
expect(response2).toEqual(transactionErrorMessageContains("Invalid secp256k1 public key signature"));
});

it("should fail when user publicKey and UserProfile are already registered", async () => {
// Given
const chaincode = new TestChaincode([PublicKeyContract]);
Expand All @@ -110,7 +142,9 @@ describe("RegisterUser", () => {
publicKey: user.publicKey,
user: user.alias
});
const signedRegisterDto = registerDto.signed(process.env.DEV_ADMIN_PRIVATE_KEY as string);
const signedRegisterDto = registerDto
.withPublicKeySignedBy(user.privateKey)
.signed(process.env.DEV_ADMIN_PRIVATE_KEY as string);

// When
const registerResponse = await chaincode.invoke("PublicKeyContract:RegisterUser", signedRegisterDto);
Expand All @@ -128,7 +162,9 @@ describe("RegisterUser", () => {
publicKey: user.publicKey,
user: "client|new_user" as UserAlias
});
const signedRegisterDto = registerDto.signed(process.env.DEV_ADMIN_PRIVATE_KEY as string);
const signedRegisterDto = registerDto
.withPublicKeySignedBy(user.privateKey)
.signed(process.env.DEV_ADMIN_PRIVATE_KEY as string);

// When
const registerResponse = await chaincode.invoke("PublicKeyContract:RegisterUser", signedRegisterDto);
Expand All @@ -137,6 +173,81 @@ describe("RegisterUser", () => {
expect(registerResponse).toEqual(expect.objectContaining({ Status: 0, ErrorKey: "PROFILE_EXISTS" }));
});

it("should register user with valid public key signature", async () => {
// Given
const chaincode = new TestChaincode([PublicKeyContract]);
const newPrivateKey = "62fa12aaf85829fab618755747a7f75c256bfc5ceab2cc24c668c55f1985cfad";
const newPublicKey =
"040e8bda5af346c5a7a7312a94b34023e8c9610abf40e550de9696422312a9a67ea748dbe2686f9a115c58021fe538163285a97368f44b6bf8b13a8306c86e8c5a";
const ethAddress = signatures.getEthAddress(newPublicKey);

const registerDto = await createValidSubmitDTO(RegisterUserDto, {
user: "client|user-with-signature" as UserAlias,
publicKey: newPublicKey
});
const signedRegisterDto = registerDto
.withPublicKeySignedBy(newPrivateKey)
.signed(process.env.DEV_ADMIN_PRIVATE_KEY as string);

// When
const response = await chaincode.invoke("PublicKeyContract:RegisterUser", signedRegisterDto);

// Then
expect(response).toEqual(transactionSuccess());

expect(await getPublicKey(chaincode, registerDto.user)).toEqual(
transactionSuccess({
publicKey: PublicKeyService.normalizePublicKey(newPublicKey)
})
);

expect(await getUserProfile(chaincode, ethAddress)).toEqual(
transactionSuccess({
alias: registerDto.user,
ethAddress,
roles: UserProfile.DEFAULT_ROLES,
signatureQuorum: 1
})
);
});

it("should reject registration with missing or invalid public key signature", async () => {
// Given
const chaincode = new TestChaincode([PublicKeyContract]);
const newPublicKey =
"040e8bda5af346c5a7a7312a94b34023e8c9610abf40e550de9696422312a9a67ea748dbe2686f9a115c58021fe538163285a97368f44b6bf8b13a8306c86e8c5a";
// Wrong private key that doesn't correspond to newPublicKey
const wrongPrivateKey = "0000000000000000000000000000000000000000000000000000000000000001";

const registerDto = await createValidSubmitDTO(RegisterUserDto, {
user: "client|user-missing-sig" as UserAlias,
publicKey: newPublicKey
});

// Missing public key signature
const signedRegisterDto1 = registerDto.signed(process.env.DEV_ADMIN_PRIVATE_KEY as string);
expect(signedRegisterDto1.publicKeySignature).toBeUndefined();

// Invalid public key signature (signed by wrong private key that doesn't match newPublicKey)
const signedRegisterDto2 = registerDto
.withPublicKeySignedBy(wrongPrivateKey)
.signed(process.env.DEV_ADMIN_PRIVATE_KEY as string);
expect(signedRegisterDto2.publicKeySignature).toBeDefined();

// When
const registerResponse1 = await chaincode.invoke("PublicKeyContract:RegisterUser", signedRegisterDto1);
const registerResponse2 = await chaincode.invoke("PublicKeyContract:RegisterUser", signedRegisterDto2);

// Then
expect(registerResponse1).toEqual(transactionErrorKey("VALIDATION_FAILED"));
expect(registerResponse1).toEqual(transactionErrorMessageContains("Public key signature is missing"));

expect(registerResponse2).toEqual(transactionErrorKey("VALIDATION_FAILED"));
expect(registerResponse2).toEqual(
transactionErrorMessageContains("Invalid secp256k1 public key signature")
);
});

// TODO: this test will be redesigned in a follow-up story
it.skip("should fail when migrating existing user to UserProfile, but PublicKey doesn't match", async () => {
// Given
Expand Down Expand Up @@ -222,6 +333,8 @@ describe("RegisterUser", () => {

it("RegisterEthUser should register user with eth address", async () => {
// Given
const keyPair = signatures.genKeyPair();
const publicKey = keyPair.publicKey;
const pkHex = signatures.getNonCompactHexPublicKey(publicKey);
const ethAddress = signatures.getEthAddress(pkHex);
const alias = `eth|${ethAddress}` as UserAlias;
Expand Down Expand Up @@ -359,7 +472,9 @@ describe("UpdatePublicKey", () => {
user: "client|newUser" as UserAlias,
publicKey: oldPublicKey
});
const signedDto = dto.signed(process.env.DEV_ADMIN_PRIVATE_KEY as string);
const signedDto = dto
.withPublicKeySignedBy(oldPrivateKey)
.signed(process.env.DEV_ADMIN_PRIVATE_KEY as string);

// When
const response = await chaincode.invoke("PublicKeyContract:RegisterUser", signedDto);
Expand Down
3 changes: 2 additions & 1 deletion chaincode/src/contracts/PublicKeyContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ export class PublicKeyContract extends GalaContract {
dto.publicKey,
signerAliases.length ? signerAliases : undefined,
dto.user,
signatureQuorum
signatureQuorum,
dto as ChainCallDTO & { publicKeySignature?: string }
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,11 @@ The key is generated by the caller and should be unique for each DTO. You can us
"minLength": 1,
"type": "string",
},
"publicKeySignature": {
"description": "Signature from the public key.",
"minLength": 1,
"type": "string",
},
"signature": {
"description": "Signature of the DTO signed with caller's private key to be verified with user's public key saved on chain. The 'signature' field is optional for DTO, but is required for a transaction to be executed on chain.
Please consult [GalaChain SDK documentation](https://github.com/GalaChain/sdk/blob/main/docs/authorization.md#signature-based-authorization) on how to create signatures.",
Expand Down
2 changes: 1 addition & 1 deletion chaincode/src/contracts/authenticate.testutils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export async function createUser(): Promise<User> {
export async function createRegisteredUser(chaincode: TestChaincode): Promise<User> {
const { alias, privateKey, publicKey, ethAddress } = await createUser();
const dto = await createValidSubmitDTO(RegisterUserDto, { user: alias, publicKey });
const signedDto = dto.signed(process.env.DEV_ADMIN_PRIVATE_KEY as string);
const signedDto = dto.withPublicKeySignedBy(privateKey).signed(process.env.DEV_ADMIN_PRIVATE_KEY as string);
const response = await chaincode.invoke("PublicKeyContract:RegisterUser", signedDto);
expect(response).toEqual(transactionSuccess());
return { alias, privateKey, publicKey, ethAddress };
Expand Down
21 changes: 20 additions & 1 deletion chaincode/src/services/PublicKeyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,8 @@ export class PublicKeyService {
publicKey: string | undefined,
signers: UserAlias[] | undefined,
userAlias: UserAlias,
signatureQuorum: number
signatureQuorum: number,
dto?: ChainCallDTO & { publicKeySignature?: string }
): Promise<string> {
if (publicKey && signers) {
throw new ValidationFailedError("Cannot use both publicKey and signers");
Expand All @@ -238,6 +239,24 @@ export class PublicKeyService {
throw new ValidationFailedError(`Found duplicate signers in: ${signers.join(",")}`);
}

// Validate public key signature when publicKey is provided (for single-signed users)
if (publicKey && !signers && dto) {
const publicKeySignature = dto.publicKeySignature;

if (publicKeySignature === undefined) {
throw new ValidationFailedError("Public key signature is missing");
}

// Create DTO without signature fields for validation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { publicKeySignature: _, ...dtoRemaining } = dto;

const isSignatureValid = signatures.isValidSignature(publicKeySignature, dtoRemaining, publicKey);
if (!isSignatureValid) {
throw new ValidationFailedError(`Invalid secp256k1 public key signature`);
}
}

const currPublicKey = await PublicKeyService.getPublicKey(ctx, userAlias);

if (currPublicKey) {
Expand Down
Loading