@dfinity/identity
Version:
JavaScript and TypeScript library to manage identity with the Internet Computer
266 lines (231 loc) • 8.61 kB
text/typescript
import {
type DerEncodedPublicKey,
type PublicKey,
type Signature,
SignIdentity,
wrapDER,
DER_COSE_OID,
Cbor,
} from '@dfinity/agent';
import { bytesToHex, hexToBytes, randomBytes, bytesToUtf8 } from '@noble/hashes/utils';
import { uint8FromBufLike } from '@dfinity/candid';
function _coseToDerEncodedBlob(cose: Uint8Array): DerEncodedPublicKey {
return wrapDER(cose, DER_COSE_OID) as DerEncodedPublicKey;
}
type PublicKeyCredentialWithAttachment = PublicKeyCredential & {
// Extends `PublicKeyCredential` with an optional field introduced in the WebAuthn level 3 spec:
// https://w3c.github.io/webauthn/#dom-publickeycredential-authenticatorattachment
// Already supported by Chrome, Safari and Edge
// Note: `null` is included here as a possible value because Edge set this value to null in the
// past.
authenticatorAttachment: AuthenticatorAttachment | undefined | null;
// Explicitly including toJSON method from the base PublicKeyCredential interface
toJSON: () => Record<string, unknown>;
};
/**
* From the documentation;
* The authData is a byte array described in the spec. Parsing it will involve slicing bytes from
* the array and converting them into usable objects.
*
* See https://webauthn.guide/#registration (subsection "Example: Parsing the authenticator data").
* @param authData The authData field of the attestation response.
* @returns The COSE key of the authData.
*/
function _authDataToCose(authData: Uint8Array): Uint8Array {
const dataView = new DataView(new ArrayBuffer(2));
const idLenBytes = authData.slice(53, 55);
[...new Uint8Array(idLenBytes)].forEach((v, i) => dataView.setUint8(i, v));
const credentialIdLength = dataView.getUint16(0);
// Get the public key object.
return authData.slice(55 + credentialIdLength);
}
export class CosePublicKey implements PublicKey {
protected _encodedKey: DerEncodedPublicKey;
public constructor(protected _cose: Uint8Array) {
this._encodedKey = _coseToDerEncodedBlob(_cose);
}
public toDer(): DerEncodedPublicKey {
return this._encodedKey;
}
public getCose(): Uint8Array {
return this._cose;
}
}
/**
* Create a challenge from a string or array. The default challenge is always the same
* because we don't need to verify the authenticity of the key on the server (we don't
* register our keys with the IC). Any challenge would do, even one per key, randomly
* generated.
* @param challenge The challenge to transform into a byte array. By default a hard
* coded string.
*/
function _createChallengeBuffer(challenge: string | Uint8Array = '<ic0.app>'): Uint8Array {
if (typeof challenge === 'string') {
return Uint8Array.from(challenge, c => c.charCodeAt(0));
} else {
return challenge;
}
}
/**
* Create a credentials to authenticate with a server. This is necessary in order in
* WebAuthn to get credentials IDs (which give us the public key and allow us to
* sign), but in the case of the Internet Computer, we don't actually need to register
* it, so we don't.
* @param credentialCreationOptions an optional CredentialCreationOptions object
*/
async function _createCredential(
credentialCreationOptions?: CredentialCreationOptions,
): Promise<PublicKeyCredentialWithAttachment | null> {
const creds = (await navigator.credentials.create(
credentialCreationOptions ?? {
publicKey: {
authenticatorSelection: {
userVerification: 'preferred',
},
attestation: 'direct',
challenge: _createChallengeBuffer(),
pubKeyCredParams: [{ type: 'public-key', alg: PubKeyCoseAlgo.ECDSA_WITH_SHA256 }],
rp: {
name: 'Internet Identity Service',
},
user: {
id: randomBytes(16),
name: 'Internet Identity',
displayName: 'Internet Identity',
},
},
},
)) as PublicKeyCredentialWithAttachment | null;
if (creds === null) {
return null;
}
return {
// do _not_ use ...creds here, as creds is not enumerable in all cases
id: creds.id,
response: creds.response,
type: creds.type,
authenticatorAttachment: creds.authenticatorAttachment,
getClientExtensionResults: creds.getClientExtensionResults,
// Some password managers will return a Uint8Array, so we ensure we return an ArrayBuffer.
rawId: creds.rawId,
toJSON: creds.toJSON.bind(creds), // Ensure the toJSON method is included
};
}
// See https://www.iana.org/assignments/cose/cose.xhtml#algorithms for a complete
// list of these algorithms. We only list the ones we support here.
enum PubKeyCoseAlgo {
ECDSA_WITH_SHA256 = -7,
}
/**
* A SignIdentity that uses `navigator.credentials`. See https://webauthn.guide/ for
* more information about WebAuthentication.
*/
export class WebAuthnIdentity extends SignIdentity {
/**
* Create an identity from a JSON serialization.
* @param json - json to parse
*/
public static fromJSON(json: string): WebAuthnIdentity {
const { publicKey, rawId } = JSON.parse(json);
if (typeof publicKey !== 'string' || typeof rawId !== 'string') {
throw new Error('Invalid JSON string.');
}
return new this(hexToBytes(rawId), hexToBytes(publicKey), undefined);
}
/**
* Create an identity.
* @param credentialCreationOptions an optional CredentialCreationOptions Challenge
*/
public static async create(
credentialCreationOptions?: CredentialCreationOptions,
): Promise<WebAuthnIdentity> {
const creds = await _createCredential(credentialCreationOptions);
if (!creds || creds.type !== 'public-key') {
throw new Error('Could not create credentials.');
}
const response = creds.response as AuthenticatorAttestationResponse;
if (response.attestationObject === undefined) {
throw new Error('Was expecting an attestation response.');
}
// Parse the attestationObject as CBOR.
const attObject = Cbor.decode<{ authData: Uint8Array }>(
new Uint8Array(response.attestationObject),
);
return new this(
uint8FromBufLike(creds.rawId),
_authDataToCose(attObject.authData),
creds.authenticatorAttachment ?? undefined,
);
}
protected _publicKey: CosePublicKey;
public constructor(
public readonly rawId: Uint8Array,
cose: Uint8Array,
protected authenticatorAttachment: AuthenticatorAttachment | undefined,
) {
super();
this._publicKey = new CosePublicKey(cose);
}
public getPublicKey(): PublicKey {
return this._publicKey;
}
/**
* WebAuthn level 3 spec introduces a new attribute on successful WebAuthn interactions,
* see https://w3c.github.io/webauthn/#dom-publickeycredential-authenticatorattachment.
* This attribute is already implemented for Chrome, Safari and Edge.
*
* Given the attribute is only available after a successful interaction, the information is
* provided opportunistically and might also be `undefined`.
*/
public getAuthenticatorAttachment(): AuthenticatorAttachment | undefined {
return this.authenticatorAttachment;
}
public async sign(blob: Uint8Array): Promise<Signature> {
const result = (await navigator.credentials.get({
publicKey: {
allowCredentials: [
{
type: 'public-key',
id: this.rawId,
},
],
challenge: blob,
userVerification: 'preferred',
},
})) as PublicKeyCredentialWithAttachment;
if (result.authenticatorAttachment !== null) {
this.authenticatorAttachment = result.authenticatorAttachment;
}
const response = result.response as AuthenticatorAssertionResponse;
const encoded = Cbor.encode({
authenticator_data: response.authenticatorData,
client_data_json: bytesToUtf8(new Uint8Array(response.clientDataJSON)),
signature: response.signature,
});
if (!encoded) {
throw new Error('failed to encode cbor');
}
Object.assign(encoded, {
__signature__: undefined,
});
return encoded as Signature;
}
/**
* Allow for JSON serialization of all information needed to reuse this identity.
*/
public toJSON(): JsonnableWebAuthnIdentity {
return {
publicKey: bytesToHex(this._publicKey.getCose()),
rawId: bytesToHex(this.rawId),
};
}
}
/**
* ReturnType<WebAuthnIdentity.toJSON>
*/
export interface JsonnableWebAuthnIdentity {
// The hexadecimal representation of the DER encoded public key.
publicKey: string;
// The string representation of the local WebAuthn Credential.id (base64url encoded).
rawId: string;
}