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

Bug: Domain only validation doesn't work for custom schemes #205

Merged
Merged
Show file tree
Hide file tree
Changes from 5 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
6 changes: 4 additions & 2 deletions libraries/js/src/mocks/payloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import {
serializeItemActionsPayloadHex,
} from '../util.js';

function generateLoginMessage(account: string, issued: Date, expires: Date, domain: string) {
return `${domain} wants you to sign in with your Frequency account:\n${account}\n\n\n\nURI: https://testnet.frequencyaccess.com/signin/confirm\nNonce: N6rLwqyz34oUxJEXJ\nIssued At: ${issued.toISOString()}\nExpiration Time: ${expires.toISOString()}`;
function generateLoginMessage(account: string, issued: Date, expires: Date, uri: URL, domain: string) {
return `${domain} wants you to sign in with your Frequency account:\n${account}\n\n\n\nURI: ${uri}\nNonce: N6rLwqyz34oUxJEXJ\nIssued At: ${issued.toISOString()}\nExpiration Time: ${expires.toISOString()}`;
mattheworris marked this conversation as resolved.
Show resolved Hide resolved
}

// Setup now so that it is consistent for the entire test run
Expand All @@ -24,6 +24,7 @@ const loginMessageGood = (domain: string) =>
ExampleUserKey.public,
new Date(now - 24 * 60 * 60 * 1000),
new Date(now + 24 * 60 * 60 * 1000),
new URL('https://testnet.frequencyaccess.com/signin/confirm'),
domain
mattheworris marked this conversation as resolved.
Show resolved Hide resolved
);

Expand All @@ -32,6 +33,7 @@ const loginMessageExpired = () =>
ExampleUserKey.public,
new Date(now - 2 * 24 * 60 * 60 * 1000),
new Date(now - 24 * 60 * 60 * 1000),
new URL('https://testnet.frequencyaccess.com/signin/confirm'),
'localhost'
);

Expand Down
121 changes: 119 additions & 2 deletions libraries/js/src/payloads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,54 @@ Issued At: 2024-10-10T18:40:37.344099626Z`,
).resolves.toBeUndefined();
});

it('Can verify a Generated Login Payload: app scheme', async () => {
await expect(
validatePayloads(
{
userPublicKey: ExampleUserPublicKey,
payloads: [ExamplePayloadLoginGood('example://login')],
},
'example://login'
)
).resolves.toBeUndefined();
});

it('Can verify a Generated Login Payload: https://example.com/login', async () => {
await expect(
validatePayloads(
{
userPublicKey: ExampleUserPublicKey,
payloads: [ExamplePayloadLoginGood('https://example.com/login')],
},
'https://example.com/login'
)
).resolves.toBeUndefined();
});

it('Can verify a Generated Login Payload: www.example.com/login', async () => {
await expect(
validatePayloads(
{
userPublicKey: ExampleUserPublicKey,
payloads: [ExamplePayloadLoginGood('www.example.com/login')],
},
'www.example.com/login'
)
).resolves.toBeUndefined();
});

it('Can verify a Generated Login Payload: localhost:3030/login/path', async () => {
await expect(
validatePayloads(
{
userPublicKey: ExampleUserPublicKey,
payloads: [ExamplePayloadLoginGood('localhost:3030/login/path')],
},
'localhost:3030/login/path'
)
).resolves.toBeUndefined();
});

it('Will fail to verify a Login Payload with the wrong domain', async () => {
await expect(
validatePayloads(
Expand All @@ -78,7 +126,7 @@ Issued At: 2024-10-10T18:40:37.344099626Z`,
},
'localhost'
)
).rejects.toThrowError('Message does not match expected domain. Message: badhost Expected: localhost');
).rejects.toThrowError('Message does not match expected domain. Domain: badhost Expected: localhost');

await expect(
validatePayloads(
Expand All @@ -88,7 +136,76 @@ Issued At: 2024-10-10T18:40:37.344099626Z`,
},
'betterhost'
)
).rejects.toThrowError('Message does not match expected domain. Message: localhost Expected: betterhost');
).rejects.toThrowError('Message does not match expected domain. Domain: localhost Expected: betterhost');
});

it('Will fail to verify a Generated Login Payload with an incorrect app scheme', async () => {
await expect(
validatePayloads(
{
userPublicKey: ExampleUserPublicKey,
payloads: [ExamplePayloadLoginGood('not_example://login')],
},
'example://login'
)
).rejects.toThrowError(
'Message does not match expected domain. Domain scheme mismatch. Scheme: not_example Expected: example'
);
});

it('Will fail to verify a Generated Login Payload with an incorrect https scheme', async () => {
// Check the scheme
await expect(
validatePayloads(
{
userPublicKey: ExampleUserPublicKey,
payloads: [ExamplePayloadLoginGood('http://example.com/login')],
},
'https://example.com/login'
)
).rejects.toThrowError(
'Message does not match expected domain. Domain scheme mismatch. Scheme: http Expected: https'
);

// Check the domain
await expect(
validatePayloads(
{
userPublicKey: ExampleUserPublicKey,
payloads: [ExamplePayloadLoginGood('https://www.examples.com/login')],
},
'https://www.example.com/login'
)
).rejects.toThrowError(
'Message does not match expected domain. Domain: www.examples.com Expected: www.example.com'
);

// Check the path
await expect(
validatePayloads(
{
userPublicKey: ExampleUserPublicKey,
payloads: [ExamplePayloadLoginGood('https://www.example.com/logins')],
},
'https://www.example.com/login'
)
).rejects.toThrowError(
'Message does not match expected domain. Domain path mismatch. Path: logins Expected: login'
);
});

it('Will fail to verify a Generated Login Payload with an incorrect www scheme', async () => {
await expect(
validatePayloads(
{
userPublicKey: ExampleUserPublicKey,
payloads: [ExamplePayloadLoginGood('www.examples.com/login')],
},
'www.example.com/login'
)
).rejects.toThrowError(
'Message does not match expected domain. Domain: www.examples.com Expected: www.example.com'
);
});

it('Can verify a Static Login Payload', async () => {
Expand Down
96 changes: 92 additions & 4 deletions libraries/js/src/payloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,22 @@ interface SiwxMessage {
uri: string;
}

/**
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added: helpful for hover over in IDE

* Parses a given message string and extracts various components into a SiwxMessage object.
*
* @param message - The message string to be parsed.
* @returns An object containing the parsed components:
* - `domain`: The domain extracted from the first line of the message.
* - `address`: The address extracted from the second line of the message.
* - `nonce`: The nonce extracted from the message.
* - `uri`: The URI extracted from the message.
* - `expired`: A boolean indicating whether the message has expired.
* - `issuedAt`: The date and time when the message was issued.
* - `expirationTime`: The date and time when the message expires, if available.
*/
function parseMessage(message: string): SiwxMessage {
const msgSplit = message.split('\n');
// TODO: Does this need to be updated for app-specific needs?
mattheworris marked this conversation as resolved.
Show resolved Hide resolved
const domain = (msgSplit[0] || '').split(' ')[0] || '';

const addressLines = (msgSplit[1] || '').split(':');
Expand Down Expand Up @@ -75,6 +89,14 @@ function verifySignatureHashMaybeWrapped(publicKey: string, signature: string, m
return wrappedVerifyResult.isValid;
}

/**
* Verifies a signature against a given public key and message. This function supports both wrapped and unwrapped signatures.
*
* @param publicKey - The public key used to verify the signature.
* @param signature - The signature to be verified.
* @param message - The original message that was signed.
* @returns `true` if the signature is valid, `false` otherwise.
*/
function verifySignatureMaybeWrapped(publicKey: string, signature: string, message: Uint8Array): boolean {
const unwrappedVerifyResult = signatureVerify(message, signature, publicKey);
if (unwrappedVerifyResult.isValid) {
Expand All @@ -92,6 +114,72 @@ function expect(test: boolean, errorMessage: string) {
if (!test) throw new Error(errorMessage);
}

/**
* Validates that the provided message domain matches the expected domain.
*
* This function checks the following:
* 1. If the domains have schemes (e.g., `https://`), the schemes must match.
* 2. The paths within the domains must match.
* 3. The domains themselves must match.
*
* @param msgDomain - The domain from the message to be validated. This can include an optional scheme and path.
* @param expectedDomain - The expected domain to validate against. This can include an optional scheme and path.
*
* @throws Will throw an error if the schemes, paths, or domains do not match.
*/
function validateDomain(msgDomain: string, expectedDomain: string) {
mattheworris marked this conversation as resolved.
Show resolved Hide resolved
/* eslint-disable prefer-const */
let msgParsedScheme: string | null;
let expectedParsedScheme: string | null;
let msgParsedDomain: string | undefined;
let expectedParsedDomain: string | undefined;
let msgParsedPath: string | undefined;
let expectedParsedPath: string | undefined;

// Parse out the optional scheme from the domain
// SIWF_V2_DOMAIN may be configured with or without a scheme that accommodates app-specific needs.
mattheworris marked this conversation as resolved.
Show resolved Hide resolved
// e.g. `example://login`, `https://www.example.com/login` or `example.com/login` are all valid.
[msgParsedScheme, msgParsedDomain] = msgDomain.includes('://') ? msgDomain.split('://', 2) : [null, msgDomain];
mattheworris marked this conversation as resolved.
Show resolved Hide resolved

[expectedParsedScheme, expectedParsedDomain] = expectedDomain.includes('://')
? expectedDomain.split('://', 2)
: [null, expectedDomain];

// If the domain or expected domain has a scheme, the scheme must match
expect(
msgParsedScheme === expectedParsedScheme,
`Message does not match expected domain. Domain scheme mismatch. Scheme: ${msgParsedScheme} Expected: ${expectedParsedScheme}`
);

// Parse the path from the domain
// e.g. 'example.com/path' -> 'path'
// e.g. 'example/path' -> 'path'
[msgParsedDomain, msgParsedPath] = msgParsedDomain?.split('/', 2) ?? [msgParsedDomain, ''];
[expectedParsedDomain, expectedParsedPath] = expectedParsedDomain?.split('/', 2) ?? [expectedParsedDomain, ''];
expect(
msgParsedPath === expectedParsedPath,
`Message does not match expected domain. Domain path mismatch. Path: ${msgParsedPath} Expected: ${expectedParsedPath}`
);

expect(
msgParsedDomain === expectedParsedDomain,
`Message does not match expected domain. Domain: ${msgParsedDomain} Expected: ${expectedParsedDomain}`
);
/* eslint-enable prefer-const */
}

/**
* Validates the login payload by verifying the signature, message contents, domain, and address.
*
* @param payload - The login payload to validate.
* @param userPublicKey - The public key of the user.
* @param loginMsgDomain - The expected domain of the login message.
mattheworris marked this conversation as resolved.
Show resolved Hide resolved
*
* @throws Will throw an error if the signature verification fails.
* @throws Will throw an error if the domain validation fails.
* @throws Will throw an error if the address decoding or comparison fails.
* @throws Will throw an error if the message has expired.
*/
function validateLoginPayload(
payload: SiwfResponsePayloadLogin,
userPublicKey: SiwfPublicKey,
Expand All @@ -109,10 +197,10 @@ function validateLoginPayload(

// Validate the message contents
const msg = parseMessage(payload.payload.message);
expect(
msg.domain === loginMsgDomain,
`Message does not match expected domain. Message: ${msg.domain} Expected: ${loginMsgDomain}`
);

// Validate the domain
validateDomain(msg.domain, loginMsgDomain);
mattheworris marked this conversation as resolved.
Show resolved Hide resolved

// Match address encoding before comparing
// decodeAddress will throw if it cannot decode meaning bad address
try {
Expand Down
14 changes: 13 additions & 1 deletion libraries/js/src/response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,19 @@ describe('validateSiwfResponse', () => {
it('throws on a bad domain', async () => {
const example = await ExampleLogin();
await expect(validateSiwfResponse(base64url(JSON.stringify(example)), 'bad.example.xyz')).to.rejects.toThrowError(
'Message does not match expected domain. Message: localhost Expected: bad.example.xyz'
'Message does not match expected domain. Domain: localhost Expected: bad.example.xyz'
);
});

it('throws on a bad scheme in domain', async () => {
const example = await ExampleLogin();
await expect(validateSiwfResponse(base64url(JSON.stringify(example)), 'example://login')).to.rejects.toThrowError(
'Message does not match expected domain. Domain scheme mismatch. Scheme: null Expected: example'
);

// const badSchemeExample = await ExamplePayloadLoginGood('not_example://login');
// await expect(validateSiwfResponse(base64url(JSON.stringify(badSchemeExample)), 'example://login')).to.rejects.toThrowError(
// 'Message does not match expected domain. Domain scheme mismatch. Scheme: local Expected: example'
// );
});
});
2 changes: 1 addition & 1 deletion libraries/js/src/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export async function getLoginResult(
const response = await fetch(endpoint);

if (!response.ok) {
throw new Error(`Response failed: ${response.status} ${response.statusText}`);
throw new Error(`Response failed: ${response.status} ${response.statusText} (${endpoint})`);
}

const body = await response.json();
Expand Down
9 changes: 9 additions & 0 deletions libraries/js/src/types/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@ function isPayloadBase(obj: any): obj is SiwfResponsePayloadBase {
return isObj(obj) && isStr(obj.type) && isPayloadSignature(obj.signature) && isObj(obj.payload);
}

/**
* Checks if the given object is a `SiwfResponsePayloadLogin`.
*
* This function verifies that the object is a valid payload base and that its type is 'login'.
* Additionally, it ensures that the `message` property in the payload is a string.
*
* @param obj - The object to check.
* @returns `true` if the object is a `SiwfResponsePayloadLogin`, otherwise `false`.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isPayloadLogin(obj: any): obj is SiwfResponsePayloadLogin {
return isPayloadBase(obj) && obj.type === 'login' && isStr(obj.payload.message);
Expand Down