@aptos-labs/siwa
Version:
Library of utilities for Sign in with Aptos
298 lines (270 loc) • 9.69 kB
text/typescript
import {
AccountAddress,
AccountPublicKey,
type Aptos,
type PublicKey,
type Signature,
} from "@aptos-labs/ts-sdk";
import type {
AptosSignInBoundFields,
AptosSignInInput,
} from "@aptos-labs/wallet-standard";
import { sha3_256 } from "@noble/hashes/sha3";
import { arraysEqual, asyncTryOrDefault, mainnet } from "./internal.js";
import { verifySignature } from "./signatures.js";
import type {
VerificationError,
VerificationMessageError,
VerificationResult,
VerificationResultWithData,
} from "./types.js";
/**
* Create a SIWA message from the input following the ABNF format defined in the Sign in with Aptos specifications.
*
* @param input The input to create the SIWA message from.
*
* @returns The SIWA message text.
*/
export function createSignInMessage(
input: AptosSignInInput & AptosSignInBoundFields,
): string {
let message = `${input.domain} wants you to sign in with your Aptos account:\n`;
message += `${input.address}`;
if (input.statement) {
message += `\n\n${input.statement}`;
}
const fields: string[] = [];
if (input.uri) {
fields.push(`URI: ${input.uri}`);
}
if (input.version) {
fields.push(`Version: ${input.version}`);
}
if (input.nonce) {
fields.push(`Nonce: ${input.nonce}`);
}
if (input.issuedAt) {
fields.push(`Issued At: ${input.issuedAt}`);
}
if (input.expirationTime) {
fields.push(`Expiration Time: ${input.expirationTime}`);
}
if (input.notBefore) {
fields.push(`Not Before: ${input.notBefore}`);
}
if (input.requestId) {
fields.push(`Request ID: ${input.requestId}`);
}
if (input.chainId) {
fields.push(`Chain ID: ${input.chainId}`);
}
if (input.resources) {
fields.push("Resources:");
for (const resource of input.resources) {
fields.push(`- ${resource}`);
}
}
if (fields.length) {
message += `\n\n${fields.join("\n")}`;
}
return message;
}
/**
* Generate a signing message using the SIWA signing algorithm.
* ( sha3_256(b"SIGN_IN_WITH_APTOS::" ) || <message> )
*
* @param message The SIWA message to sign.
*
* @returns The signing message.
*/
export function createSignInSigningMessage(message: string): Uint8Array {
const domainSeparator = "SIGN_IN_WITH_APTOS::";
const domainSeparatorHash = sha3_256(domainSeparator);
return new Uint8Array([
...domainSeparatorHash,
...new TextEncoder().encode(message),
]);
}
const DOMAIN =
"(?<domain>[^\\n]+?) wants you to sign in with your Aptos account:\\n";
const ADDRESS = "(?<address>[^\\n]+)(?:\\n|$)";
const STATEMENT = "(?:\\n(?<statement>[\\S\\s]*?)(?:\\n|$))??";
const URI = "(?:\\nURI: (?<uri>[^\\n]+))?";
const VERSION = "(?:\\nVersion: (?<version>[^\\n]+))?";
const NONCE = "(?:\\nNonce: (?<nonce>[^\\n]+))?";
const ISSUED_AT = "(?:\\nIssued At: (?<issuedAt>[^\\n]+))?";
const EXPIRATION_TIME = "(?:\\nExpiration Time: (?<expirationTime>[^\\n]+))?";
const NOT_BEFORE = "(?:\\nNot Before: (?<notBefore>[^\\n]+))?";
const REQUEST_ID = "(?:\\nRequest ID: (?<requestId>[^\\n]+))?";
const CHAIN_ID = "(?:\\nChain ID: (?<chainId>[^\\n]+))?";
const RESOURCES = "(?:\\nResources:(?<resources>(?:\\n- [^\\n]+)*))?";
const FIELDS = `${URI}${VERSION}${NONCE}${ISSUED_AT}${EXPIRATION_TIME}${NOT_BEFORE}${REQUEST_ID}${CHAIN_ID}${RESOURCES}`;
const MESSAGE = new RegExp(`^${DOMAIN}${ADDRESS}${STATEMENT}${FIELDS}\\n*$`);
/**
* Parse a SIWA message into an `AptosSignInInput` object with the required fields.
*
* @param text The SIWA message to parse.
*
* @returns The parsed `AptosSignInInput` object with the required fields.
*/
export function parseSignInMessage(
text: string,
): VerificationResultWithData<AptosSignInInput & AptosSignInBoundFields> {
const match = MESSAGE.exec(text);
if (!match) return { valid: false, errors: ["invalid_message"] };
const groups = match.groups;
if (!groups) return { valid: false, errors: ["invalid_message"] };
const errors: VerificationMessageError[] = [];
if (!groups.domain || groups.domain === "undefined")
errors.push("message_domain_missing");
if (!groups.address || groups.address === "undefined")
errors.push("message_address_missing");
if (!groups.version || groups.version === "undefined")
errors.push("message_version_missing");
if (!groups.chainId || groups.chainId === "undefined")
errors.push("message_chain_id_missing");
if (errors.length) return { valid: false, errors };
return {
valid: true,
data: {
domain: groups.domain,
address: groups.address,
statement: groups.statement,
uri: groups.uri,
version: groups.version,
nonce: groups.nonce,
chainId: groups.chainId,
issuedAt: groups.issuedAt,
expirationTime: groups.expirationTime,
notBefore: groups.notBefore,
requestId: groups.requestId,
resources: groups.resources?.split("\n- ").slice(1),
},
};
}
/**
* Verifies a SIWA plain text message against expected `AptosSignInInput` fields (including required fields).
*
* @param params.publicKey The public key of the user that is signing in.
* @param params.expected The expected fields to verify against the input.
* @param params.message The SIWA plain text message to verify.
*
* @param options.aptosConfig The Aptos configuration to use for the verification.
* @param options.excludedResources The resources to exclude from the verification.
*
* @returns The verification result.
*/
export async function verifySignInMessage(
params: {
publicKey: PublicKey;
// From the beginning of the flow
expected: AptosSignInInput & { domain: string };
// From wallet, AptosSignInOutput
input: AptosSignInInput & AptosSignInBoundFields;
},
options: { aptos?: Aptos; excludedResources?: string[] } = {},
): Promise<VerificationResult> {
const { expected, input, publicKey } = params;
if (!(publicKey instanceof AccountPublicKey)) {
return { valid: false, errors: ["invalid_public_key"] };
}
// 1. Check that the authentication key of the account at `input.address` matches the `PublicKey`'s derived authentication key
const accountAddress = input.address;
const accountAuthenticationKey = await asyncTryOrDefault(
async () =>
(await (options.aptos ?? mainnet).getAccountInfo({ accountAddress }))
.authentication_key,
accountAddress,
);
const publicKeyAuthenticationKey = publicKey.authKey().derivedAddress();
if (
!AccountAddress.from(accountAuthenticationKey, {
maxMissingChars: 63,
}).equals(publicKeyAuthenticationKey)
) {
return { valid: false, errors: ["invalid_auth_key"] };
}
// 2. Check if the `expected` fields match the `input` fields
const errors: VerificationError[] = [];
if (expected.domain && expected.domain !== input.domain)
errors.push("message_domain_mismatch");
if (expected.address && expected.address !== input.address)
errors.push("message_address_mismatch");
if (expected.statement !== input.statement)
errors.push("message_statement_mismatch");
if (expected.uri && expected.uri !== input.uri)
errors.push("message_uri_mismatch");
if (expected.version && expected.version !== input.version)
errors.push("message_version_mismatch");
if (expected.chainId && expected.chainId !== input.chainId)
errors.push("message_chain_id_mismatch");
if (expected.nonce !== input.nonce) errors.push("message_nonce_mismatch");
if (expected.issuedAt !== input.issuedAt)
errors.push("message_issued_at_mismatch");
if (expected.expirationTime !== input.expirationTime)
errors.push("message_expiration_time_mismatch");
if (expected.notBefore !== input.notBefore)
errors.push("message_not_before_mismatch");
if (expected.requestId !== input.requestId)
errors.push("message_request_id_mismatch");
if (expected.resources) {
if (!input.resources) {
errors.push("message_resources_missing");
} else if (
!arraysEqual(
expected.resources,
input.resources,
// If there is resource injection, exclude the resource since the expected value is not known
options?.excludedResources,
)
) {
errors.push("message_resources_mismatch");
}
} else if (input.resources) {
errors.push("message_resources_unexpected");
}
// 3. Do timebased comparisons on `expirationTime` and `notBefore`
const currentTime = new Date();
if (
expected.expirationTime &&
currentTime.getTime() >= new Date(expected.expirationTime).getTime()
) {
errors.push("message_expired");
}
if (
expected.notBefore &&
currentTime.getTime() < new Date(expected.notBefore).getTime()
) {
errors.push("message_not_yet_valid");
}
if (errors.length) return { valid: false, errors };
return { valid: true };
}
/**
* Using the `publicKey` and `signature`, verify that the `signature` is valid for the `message`.
*
* @param output The `AptosSignInOutput` to verify against the input.
*
* @returns The `AptosSignInInput` fields that are parsed from the message.
*/
export async function verifySignInSignature(
output: {
publicKey: PublicKey;
signature: Signature;
input: AptosSignInInput & AptosSignInBoundFields;
},
options: { aptos?: Aptos } = {},
): Promise<VerificationResult> {
const siwaMessage = createSignInMessage(output.input);
const signingMessage = createSignInSigningMessage(siwaMessage);
const isSignatureValid = await verifySignature(
{
publicKey: output.publicKey,
signature: output.signature,
signingMessage,
},
options,
);
if (!isSignatureValid) return { valid: false, errors: ["invalid_signature"] };
return { valid: true };
}