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
268 changes: 253 additions & 15 deletions packages/controller-utils/src/siwe.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ParsedMessage } from '@spruceid/siwe-parser';
import { detectSIWE } from './siwe';
import { detectSIWE, isValidSIWEOrigin } from './siwe';

const mockedParsedMessage = {
domain: 'example.eth',
Expand All @@ -8,21 +8,259 @@ const mockedParsedMessage = {

jest.mock('@spruceid/siwe-parser');

describe('detectSIWE', () => {
const parsedMessageMock = ParsedMessage as any;
it('returns an object with isSIWEMessage set to true and parsedMessage', () => {
parsedMessageMock.mockReturnValue(mockedParsedMessage);
const result = detectSIWE({ data: '0xVALIDDATA' });
expect(result.isSIWEMessage).toBe(true);
expect(result.parsedMessage).toBe(mockedParsedMessage);
});
describe('siwe', () => {
describe('detectSIWE', () => {
const parsedMessageMock = ParsedMessage as any;
it('returns an object with isSIWEMessage set to true and parsedMessage', () => {
parsedMessageMock.mockReturnValue(mockedParsedMessage);
const result = detectSIWE({ data: '0xVALIDDATA' });
expect(result.isSIWEMessage).toBe(true);
expect(result.parsedMessage).toBe(mockedParsedMessage);
});

it('returns an object with isSIWEMessage set to false and parsedMessage set to null', () => {
parsedMessageMock.mockImplementation(() => {
throw new Error('Invalid SIWE message');
it('returns an object with isSIWEMessage set to false and parsedMessage set to null', () => {
parsedMessageMock.mockImplementation(() => {
throw new Error('Invalid SIWE message');
});
const result = detectSIWE({ data: '0xINVALIDDATA' });
expect(result.isSIWEMessage).toBe(false);
expect(result.parsedMessage).toBeNull();
});
const result = detectSIWE({ data: '0xINVALIDDATA' });
expect(result.isSIWEMessage).toBe(false);
expect(result.parsedMessage).toBeNull();
});

describe('isValidSIWEOrigin', () => {
const msg = {
domain: 'example.com',
address: '0x0',
statement: '',
uri: 'https://example.com',
version: '1',
chainId: 1,
nonce: '',
issuedAt: '',
expirationTime: null,
notBefore: null,
requestId: 'foo',
resources: [],
};
const checks = [
{
name: 'identical domain',
Comment thread
legobeat marked this conversation as resolved.
Outdated
expected: true,
cases: [
{
domain: 'example.com',
origin: 'https://example.com',
},
{
domain: 'example.com',
origin: 'http://example.com',
},
{
domain: 'example.com',
origin: 'https://example.com:443',
},
{
domain: 'example.com',
origin: 'http://example.com:80',
},
{
domain: 'eXAMPLe.cOM',
origin: 'hTtp://ExamPLE.CoM',
},
{
domain: 'example.com',
origin: 'https://user:password@example.com',
},
{
domain: 'example.com',
origin: 'https://user@example.com',
},
{
domain: 'example.com',
origin: 'http://user:password@example.com:8090',
},
{
domain: 'example.com',
origin: 'http://user@example.com:8090',
},
{
domain: 'example.com',
origin: 'http://example.com:8090',
},
{
domain: 'example.com',
origin: 'https://example.com:8090',
},
],
},
{
name: 'matching domain and port',
expected: true,
cases: [
{
domain: 'example.com:443',
origin: 'https://example.com:443',
},
{
domain: 'example.com:443',
Comment thread
legobeat marked this conversation as resolved.
Outdated
origin: 'https://example.com',
},
{
domain: 'example.com:443',
origin: 'http://example.com:443',
},
{
domain: 'example.com:80',
origin: 'http://example.com',
},
{
domain: 'example.com:80',
origin: 'http://example.com:80',
},
{
domain: 'example.com:80',
origin: 'https://example.com:80',
},
{
domain: 'example.com:8090',
origin: 'http://example.com:8090',
},
{
domain: 'example.com:8080',
origin: 'https://example.com:8080',
},
],
},
{
name: 'matching userinfo',
expected: true,
cases: [
{
domain: 'alice@example.com',
origin: 'https://alice:password@example.com',
},
{
domain: 'alice@example.com',
origin: 'https://alice@example.com',
},
{
domain: 'alice@example.com:8090',
origin: 'https://alice@example.com:8090',
},
],
},
{
name: 'mismatching userinfo',
expected: false,
cases: [
{
domain: 'alice@example.com',
origin: 'https://bob@example.com',
},
{
domain: 'alice@example.com',
origin: 'https://example.com',
},
{
domain: 'alice@example.com:8090',
origin: 'https://bob:alice@example.com:8090',
},
],
},
{
name: 'mismatching domain',
expected: false,
cases: [
{
domain: 'example.com',
origin: 'http://www.example.com',
},
{
domain: 'www.example.com',
origin: 'http://example.com',
},
{
domain: 'example.com',
origin: 'https://foo.example.com',
},
{
domain: 'foo.example.com',
origin: 'https://example.com',
},
{
domain: 'localhost',
origin: 'http://127.0.0.1',
},
{
domain: '127.0.0.1',
origin: 'http://localhost',
},
],
},
{
name: 'mismatching port',
expected: false,
cases: [
{
domain: 'www.example.com:8091',
origin: 'http://www.example.com:8090',
},
{
domain: 'www.example.com:8091',
origin: 'https://www.example.com:8090',
},
{
domain: 'example.com:8090',
origin: 'http://example.com',
},
{
domain: '127.0.0.1:8090',
origin: 'https://127.0.0.1',
},
{
domain: 'localhost:8090',
origin: 'http://localhost',
},
{
domain: '127.0.0.1:8090',
origin: 'https://localhost',
Comment thread
legobeat marked this conversation as resolved.
Outdated
},
{
domain: '127.0.0.1:8090',
origin: 'https://localhost:8091',
},
{
domain: 'example.com:443',
origin: 'http://example.com',
},
{
domain: 'example.com:80',
origin: 'https://example.com',
},
],
},
];
for (const { name, expected, cases } of checks) {
for (const { domain, origin } of cases) {
it(`should return ${expected} for ${name} ${JSON.stringify({
domain,
origin,
})}`, () => {
const result = isValidSIWEOrigin({
from: '0x0',
origin,
siwe: {
isSIWEMessage: true,
parsedMessage: {
...msg,
domain,
},
},
});
expect(result).toBe(expected);
});
}
}
});
});
100 changes: 99 additions & 1 deletion packages/controller-utils/src/siwe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,108 @@ function msgHexToText(hex: string): string {
}
}

/**
* @type WrappedSIWERequest
*
* Sign-In With Ethereum (SIWE)(EIP-4361) message with request metadata
* @property {string} from - Subject account address
* @property {string} origin - The RFC 3986 originating authority of the signing request, including scheme
* @property {ParsedMessage} siwe - The data parsed from the message
*/
export interface WrappedSIWERequest {
from: string;
origin: string;
Comment thread
legobeat marked this conversation as resolved.
Outdated
siwe: SIWEMessage;
}

interface DomainParts {
username?: string;
hostname: string;
port?: string;
}

const DEFAULT_PORTS_BY_PROTOCOL = {
'http:': '80',
'https:': '443',
} as Record<string, string>;

/**
* Parses parts from RFC 3986 authority from EIP-4361 `domain` field.
*
* @param domain - input string
* @param originProtocol - implied protocol from origin
* @returns parsed parts
*/
export const parseDomainParts = (
domain: string,
originProtocol: string,
): DomainParts => {
if (domain.match(/^[^/:]*:\/\//u)) {
return new URL(domain);
}
Comment thread
legobeat marked this conversation as resolved.
Outdated
return new URL(`${originProtocol}//${domain}`);
};

/**
* Validates origin of a Sign-In With Ethereum (SIWE)(EIP-4361) request.
* As per spec:
* hostname must match.
* port and username must match iff specified.
Comment thread
legobeat marked this conversation as resolved.
* Protocol binding and full same-origin are currently not performed.
*
Comment thread
digiwand marked this conversation as resolved.
* @param req - Signature request
* @returns true if origin matches domain; false otherwise
*/
export const isValidSIWEOrigin = (req: WrappedSIWERequest): boolean => {
try {
const { origin, siwe } = req;

// origin = scheme://[user[:password]@]domain[:port]
// origin is supplied by environment and must match domain claim in message
if (!origin || !siwe?.parsedMessage?.domain) {
return false;
}

const originParts = new URL(origin);
const domainParts = parseDomainParts(
siwe.parsedMessage.domain,
originParts.protocol,
);

if (
domainParts.hostname.localeCompare(originParts.hostname, undefined, {
sensitivity: 'accent',
}) !== 0
) {
return false;
}

if (domainParts.port !== '' && domainParts.port !== originParts.port) {
// If origin port is not specified, protocol default is implied
return (
originParts.port === '' &&
domainParts.port === DEFAULT_PORTS_BY_PROTOCOL[originParts.protocol]
);
}

if (
domainParts.username !== '' &&
domainParts.username !== originParts.username
) {
return false;
}

return true;
} catch (e) {
log(e);
return false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

👍

}
};

/**
* A locally defined object used to provide data to identify a Sign-In With Ethereum (SIWE)(EIP-4361) message and provide the parsed message
*
* @typedef localSIWEObject
* @typedef SIWEMessage
* @param {boolean} isSIWEMessage - Does the intercepted message conform to the SIWE specification?
* @param {ParsedMessage} parsedMessage - The data parsed out of the message
*/
Expand Down